diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index ab2c678170f..6a8b4c427b6 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -192,6 +192,7 @@ jobs: . .github dotnet + docs/specs python declarative-agents diff --git a/dotnet/README.md b/dotnet/README.md index 2edb402a94b..328dfdf6840 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -33,4 +33,3 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram - [Design Documents](../docs/design) - [Architectural Decision Records](../docs/decisions) - [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview) - diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 3f1dbc60e34..b3b71c1dc71 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -656,6 +656,9 @@ + + + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 8d32d1113af..9b10ee66d5b 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -32,4 +32,7 @@ + + + diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 77468c4bc41..921412ba007 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -114,6 +114,7 @@ protected override async Task RunCoreAsync(IEnumerable ?? messages.ToList(); A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false); + FeatureUsageMarker.MarkUsed(); this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); @@ -166,6 +167,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false); + FeatureUsageMarker.MarkUsed(); this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.A2A/FeatureIndex.cs new file mode 100644 index 00000000000..a6bdd4e4a46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.A2A; + +internal enum FeatureIndex +{ + A2A = 62, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.A2A); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureIndex.cs new file mode 100644 index 00000000000..fce16692763 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +internal enum FeatureIndex +{ + CoreInMemoryHistoryProvider = 13, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureUsage.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureUsage.cs new file mode 100644 index 00000000000..9e28c797de3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/FeatureUsage.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides process-wide tracking for Agent Framework feature usage. +/// +/// +/// This type supports framework integrations and is not intended for direct use by applications. +/// Feature usage is accumulated for the lifetime of the process and does not represent invocation counts. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class FeatureUsage +{ + private const string FeatureMaskDisabledEnvironmentVariable = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"; + private const int RegistryVersion = 1; + + private static long s_low; + private static long s_high; + private static bool s_isDisabled = ReadDisabledState(); + private static TokenCache? s_cachedToken; + + /// + /// Marks a registered Agent Framework feature as used in the current process. + /// + /// The zero-based feature index in the range 0 through 127. + /// + /// is outside the range 0 through 127 and feature-usage tracking is enabled. + /// + /// + /// Marking is idempotent. A feature bit remains set for the lifetime of the process. + /// When AGENT_FRAMEWORK_FEATURE_MASK_DISABLED is set to true or 1, + /// marking is disabled and this method is a no-op. + /// + public static void MarkUsed(int index) + { + if (Volatile.Read(ref s_isDisabled)) + { + return; + } + + if ((uint)index >= 128) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Feature index must be in the range 0 through 127."); + } + + long bit = 1L << (index & 63); + if (index < 64) + { + AtomicOr(ref s_low, bit); + } + else + { + AtomicOr(ref s_high, bit); + } + } + + /// + /// Applies the current Agent Framework feature-usage token to a User-Agent value. + /// + /// The existing User-Agent value. + /// + /// to append or refresh the current token; to remove any existing + /// Agent Framework feature token. + /// + /// + /// The supplied User-Agent with at most one current (feat=vN.hex) comment, or with the feature comment + /// removed when the token is disabled, empty, or excluded. + /// + /// + /// This infrastructure method does not approve a destination and does not sanitize the supplied User-Agent. + /// Callers must independently verify that the actual request destination is approved before including the token. + /// + public static string ApplyToUserAgent(string userAgent, bool includeFeatureToken = true) + { + if (userAgent is null) + { + throw new ArgumentNullException(nameof(userAgent)); + } + + string baseUserAgent = RemoveFeatureComments(userAgent); + string? token = includeFeatureToken ? GetToken() : null; + if (token is null) + { + return baseUserAgent; + } + + return baseUserAgent.Length == 0 + ? $"(feat={token})" + : $"{baseUserAgent} (feat={token})"; + } + + internal static string? GetToken() + { + if (Volatile.Read(ref s_isDisabled)) + { + return null; + } + + long low = Volatile.Read(ref s_low); + long high = Volatile.Read(ref s_high); + if (low == 0 && high == 0) + { + return null; + } + + TokenCache? cached = Volatile.Read(ref s_cachedToken); + if (cached is not null && low == cached.Low && high == cached.High) + { + return cached.Token; + } + + string token = high == 0 + ? $"v{RegistryVersion}.{(ulong)low:x}" + : $"v{RegistryVersion}.{(ulong)high:x}{(ulong)low:x16}"; + + Volatile.Write(ref s_cachedToken, new TokenCache(low, high, token)); + return token; + } + + /// + /// Resets the process-global feature-usage state to isolate tests. + /// + /// + /// This test-only hook must not be used by production paths; production feature state is monotonic and never resets. + /// + internal static void ResetStateForTests() + { + _ = Interlocked.Exchange(ref s_low, 0); + _ = Interlocked.Exchange(ref s_high, 0); + Volatile.Write(ref s_cachedToken, null); + Volatile.Write(ref s_isDisabled, ReadDisabledState()); + } + + /// + /// Reloads the cached mask-disabled environment setting without resetting the feature mask. + /// + /// + /// This test-only hook verifies startup-cached configuration behavior without clearing observed feature state. + /// Production paths read the setting once when this type initializes. + /// + internal static void ReloadDisabledStateForTests() + => Volatile.Write(ref s_isDisabled, ReadDisabledState()); + + private static void AtomicOr(ref long location, long value) + { + if ((Volatile.Read(ref location) & value) != 0) + { + return; + } + +#if NETSTANDARD2_0 || NETFRAMEWORK + long current; + long updated; + do + { + current = Volatile.Read(ref location); + updated = current | value; + if (current == updated) + { + return; + } + } + while (Interlocked.CompareExchange(ref location, updated, current) != current); +#else + _ = Interlocked.Or(ref location, value); +#endif + } + + private static bool ReadDisabledState() + { + string? value = Environment.GetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase); + } + + private static string RemoveFeatureComments(string userAgent) + { + if (!TryFindFeatureComment(userAgent, searchFrom: 0, out int commentStart, out int commentEnd)) + { + return userAgent; + } + + var result = new StringBuilder(userAgent.Length); + int copyFrom = 0; + do + { + int removeFrom = commentStart; + int removeThrough = commentEnd; + + if (removeFrom > copyFrom && char.IsWhiteSpace(userAgent[removeFrom - 1])) + { + removeFrom--; + } + else if (removeFrom == copyFrom && + removeThrough < userAgent.Length && + char.IsWhiteSpace(userAgent[removeThrough])) + { + removeThrough++; + } + + result.Append(userAgent, copyFrom, removeFrom - copyFrom); + copyFrom = removeThrough; + } + while (TryFindFeatureComment(userAgent, commentEnd, out commentStart, out commentEnd)); + + result.Append(userAgent, copyFrom, userAgent.Length - copyFrom); + return result.ToString(); + } + + private static bool TryFindFeatureComment(string userAgent, int searchFrom, out int start, out int end) + { + const string Prefix = "(feat=v"; + + while ((start = userAgent.IndexOf(Prefix, searchFrom, StringComparison.Ordinal)) >= 0) + { + if (start > 0 && !char.IsWhiteSpace(userAgent[start - 1])) + { + searchFrom = start + Prefix.Length; + continue; + } + + int cursor = start + Prefix.Length; + int versionStart = cursor; + while (cursor < userAgent.Length && userAgent[cursor] is >= '0' and <= '9') + { + cursor++; + } + + if (cursor == versionStart || cursor >= userAgent.Length || userAgent[cursor] != '.') + { + searchFrom = start + Prefix.Length; + continue; + } + + cursor++; + int maskStart = cursor; + while (cursor < userAgent.Length && IsHexDigit(userAgent[cursor])) + { + cursor++; + } + + if (cursor == maskStart || cursor >= userAgent.Length || userAgent[cursor] != ')') + { + searchFrom = start + Prefix.Length; + continue; + } + + end = cursor + 1; + if (end == userAgent.Length || char.IsWhiteSpace(userAgent[end])) + { + return true; + } + + searchFrom = start + Prefix.Length; + } + + start = -1; + end = -1; + return false; + } + + private static bool IsHexDigit(char value) + => value is >= '0' and <= '9' + or >= 'a' and <= 'f' + or >= 'A' and <= 'F'; + + private sealed class TokenCache(long low, long high, string token) + { + public long Low { get; } = low; + + public long High { get; } = high; + + public string Token { get; } = token; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 1c735539a4a..a04cb575862 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -88,6 +88,10 @@ public void SetMessages(AgentSession? session, List messages) /// protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInMemoryHistoryProvider); +#pragma warning restore MAAI001 + State state = this._sessionState.GetOrInitializeState(context.Session); if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) @@ -102,6 +106,10 @@ protected override async ValueTask> ProvideChatHistoryA /// protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInMemoryHistoryProvider); +#pragma warning restore MAAI001 + State state = this._sessionState.GetOrInitializeState(context.Session); // Add request and response messages to the provider diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs index 06c7cbaf15d..1d084bab854 100644 --- a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicBetaServiceExtensions.cs @@ -68,6 +68,7 @@ public static ChatClientAgent AsAIAgent( chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } @@ -98,6 +99,7 @@ public static ChatClientAgent AsAIAgent( chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs index be50316b84a..5726de907a1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/AnthropicClientExtensions.cs @@ -68,6 +68,7 @@ public static ChatClientAgent AsAIAgent( chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } @@ -98,6 +99,7 @@ public static ChatClientAgent AsAIAgent( chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Anthropic/FeatureIndex.cs new file mode 100644 index 00000000000..1e3456fdba1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/FeatureIndex.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Anthropic; + +internal enum FeatureIndex +{ + Anthropic = 55, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.Anthropic); +#pragma warning restore MAAI001 + } +} + +internal sealed class FeatureUsageChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) +{ + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + FeatureUsageMarker.MarkUsed(); + return base.GetResponseAsync(messages, options, cancellationToken); + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + FeatureUsageMarker.MarkUsed(); + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs index 23772c0631c..ba6fd0316a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs @@ -88,6 +88,7 @@ protected override async Task RunCoreAsync( throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent."); } + FeatureUsageMarker.MarkUsed(); typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false); // Invoke the Copilot Studio agent with the provided messages. @@ -120,6 +121,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent."); } + FeatureUsageMarker.MarkUsed(); typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false); // Invoke the Copilot Studio agent with the provided messages. diff --git a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/FeatureIndex.cs new file mode 100644 index 00000000000..8a5b672a440 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.CopilotStudio; + +internal enum FeatureIndex +{ + CopilotStudio = 56, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.CopilotStudio); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs index bceca5ca726..36c75bfb5ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs @@ -243,6 +243,7 @@ public async Task> GetMessagesAsync(AgentSession? sessi } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(session); var partitionKey = BuildPartitionKey(state); @@ -306,6 +307,7 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(context.Session); var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList(); if (messageList.Count == 0) @@ -477,6 +479,7 @@ public async Task GetMessageCountAsync(AgentSession? session, CancellationT } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(session); var partitionKey = BuildPartitionKey(state); @@ -511,6 +514,7 @@ public async Task ClearMessagesAsync(AgentSession? session, CancellationTok } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(session); var partitionKey = BuildPartitionKey(state); diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs index c85009718ce..77b856bed04 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs @@ -109,6 +109,7 @@ public override async ValueTask CreateCheckpointAsync(string ses } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var checkpointId = Guid.NewGuid().ToString("N"); var checkpointInfo = new CheckpointInfo(sessionId, checkpointId); @@ -146,6 +147,7 @@ public override async ValueTask RetrieveCheckpointAsync(string sess } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); var id = $"{sessionId}_{key.CheckpointId}"; try @@ -175,6 +177,7 @@ public override async ValueTask> RetrieveIndexAsync( } #pragma warning restore CA1513 + FeatureUsageMarker.MarkUsed(); QueryDefinition query = withParent == null ? new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId ORDER BY c.timestamp ASC") .WithParameter("@sessionId", sessionId) diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/FeatureIndex.cs new file mode 100644 index 00000000000..14202dd0c10 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.CosmosNoSql; + +internal enum FeatureIndex +{ + AzureCosmos = 58, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.AzureCosmos); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs index 8e27a3f7b71..80895bdf10c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/AggregatorPromptAgentFactory.cs @@ -41,6 +41,7 @@ public AggregatorPromptAgentFactory(params PromptAgentFactory[] agentFactories) var agent = await agentFactory.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false); if (agent is not null) { + Declarative.FeatureUsageMarker.MarkUsed(); return agent; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs index 56c9ba43200..28f0c47fbb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/ChatClient/ChatClientPromptAgentFactory.cs @@ -43,6 +43,7 @@ public ChatClientPromptAgentFactory(IChatClient chatClient, IList? f var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory); + Declarative.FeatureUsageMarker.MarkUsed(); return Task.FromResult(agent); } diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/FeatureIndex.cs new file mode 100644 index 00000000000..c9e679c784f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Declarative; + +internal enum FeatureIndex +{ + DeclarativeAgent = 65, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.DeclarativeAgent); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs index cb12e4b1615..22d55178ba0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Declarative/PromptAgentFactory.cs @@ -49,8 +49,9 @@ public async Task CreateAsync(GptComponentMetadata promptAgent, Cancell { Throw.IfNull(promptAgent); - var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false); - return agent ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported."); + var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false) ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported."); + Declarative.FeatureUsageMarker.MarkUsed(); + return agent; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs index d22cd46f615..9649fb79250 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs @@ -59,6 +59,7 @@ public static IEndpointConventionBuilder MapDevUI( protectedGroup.MapDevUI(pattern: "/devui"); protectedGroup.MapEntities(); + FeatureUsageMarker.MarkUsed(); return protectedGroup; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/FeatureIndex.cs new file mode 100644 index 00000000000..7557cf2e75e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DevUI; + +internal enum FeatureIndex +{ + DevUI = 64, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.DevUI); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FeatureIndex.cs new file mode 100644 index 00000000000..95b445d06d1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +internal enum FeatureIndex +{ + FoundryHosting = 53, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 7aa31663503..08f9fdc57ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -70,6 +70,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser ConfigureFoundryResponsesOptions(services, configure); services.TryAddSingleton(_ => CreateDefaultAgentSessionStore()); services.TryAddSingleton(); + MarkFeatureUsed(); return services; } @@ -128,6 +129,7 @@ public static IServiceCollection AddFoundryResponses( services.TryAddSingleton(agentSessionStore); services.TryAddSingleton(); + MarkFeatureUsed(); return services; } @@ -318,9 +320,17 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild endpoints.ServiceProvider.GetRequiredService>())); responsesEndpoints.MapResponsesServer(prefix); MapReadinessIfMissing(endpoints); + MarkFeatureUsed(); return endpoints; } + private static void MarkFeatureUsed() + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.FoundryHosting); +#pragma warning restore MAAI001 + } + /// /// Configuration key the Foundry hosting platform populates with a non-empty value inside a /// hosted container. It is the documented way for container code to detect a Foundry context. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs index 5072e44c9c6..c3c8f489fd4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs @@ -1,88 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; +using Microsoft.Agents.AI.Internal; namespace Microsoft.Agents.AI.Foundry; -/// -/// Framework-wide pipeline policy that appends the agent-framework-dotnet/{version} -/// segment to outgoing User-Agent headers, mirroring the -/// agent-framework-python/{version} contract used by every Python provider package. -/// -/// -/// -/// The segment value is computed once from the Microsoft.Agents.AI.Foundry assembly's -/// . The policy is idempotent on retries: if -/// the segment is already present in the User-Agent header, the policy does not append -/// it again. -/// -/// -/// The policy is registered by FoundryChatClient on the underlying chat client's -/// OpenAIRequestPolicies hook so every outbound Foundry call carries the segment. The -/// policy is currently colocated with the Foundry package; it is expected to migrate to a -/// framework-wide location (such as Microsoft.Agents.AI) once another provider package -/// adopts the same User-Agent contract. -/// -/// -internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy +internal static class FoundryUserAgentPolicies { - /// Gets the singleton policy instance. - public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy(); - - private static readonly string s_segmentValue = CreateSegmentValue(); - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - AppendHeader(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - AppendHeader(message); - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void AppendHeader(PipelineMessage message) - { - if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) - { - // Guard against double-append on retries or when the policy - // is registered on multiple pipeline positions. - if (existing!.Contains(s_segmentValue)) - { - return; - } - - message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}"); - } - else - { - message.Request.Headers.Set("User-Agent", s_segmentValue); - } - } - - private static string CreateSegmentValue() - { - const string Name = "agent-framework-dotnet"; - - if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) - { - int pos = version.IndexOf('+'); - if (pos >= 0) - { - version = version.Substring(0, pos); - } - - if (version.Length > 0) - { - return $"{Name}/{version}"; - } - } - - return Name; - } + internal static AgentFrameworkUserAgentPolicyRegistration Registration { get; } = + new( + [ + "services.ai.azure.com", + "inference.ai.azure.com", + ], + BaseUserAgentScope.AllRequests); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs index 697d9120b8c..5c937de632d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs @@ -41,6 +41,8 @@ protected override async Task RunCoreAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryAgent); + var snapshot = TrySnapshot(options); if (snapshot is not null) { @@ -62,6 +64,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryAgent); + var snapshot = TrySnapshot(options); if (snapshot is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Evaluation/FoundryEvals.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Evaluation/FoundryEvals.cs index 5ea493fc36f..2538e5135b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Evaluation/FoundryEvals.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Evaluation/FoundryEvals.cs @@ -250,6 +250,8 @@ public async Task EvaluateAsync( "or set 'includePerAgent: false' so the evaluator only runs on the overall item."); } + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals); + // 2. Create the evaluation definition var createEvalPayload = new WireCreateEvalRequest { @@ -450,6 +452,7 @@ public static async Task EvaluateTracesAsync( ? evaluators : [Relevance, Coherence, TaskAdherence]; EnsureAllSpecsValid(resolvedEvaluators, nameof(evaluators)); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals); // Create the evaluation definition with the appropriate data source scenario object dataSourceConfig; @@ -642,6 +645,7 @@ public static async Task EvaluateFoundryTargetAsync( ? evaluators : [Relevance, Coherence, TaskAdherence]; EnsureAllSpecsValid(resolvedEvaluators, nameof(evaluators)); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals); var createEvalPayload = new WireCreateEvalRequest { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FeatureIndex.cs new file mode 100644 index 00000000000..245f8fd15c7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FeatureIndex.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry; + +internal enum FeatureIndex +{ + FoundryChatClient = 48, + FoundryAgent = 49, + FoundryMemory = 50, + FoundryEvals = 51, + FoundryToolbox = 52, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs index e1f8721fee6..ef239004d3a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs @@ -74,7 +74,7 @@ internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId) { this._aiProjectClient = aiProjectClient; this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId); - TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + _ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } @@ -93,7 +93,7 @@ internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agent this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); this._baseChatOptions = baseChatOptions; this.AgentName = agentReference.Name; - TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + _ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } @@ -159,7 +159,7 @@ private FoundryChatClient(AgentEndpointInner inner) this._aiProjectClient = inner.AIProjectClient; this.AgentName = inner.AgentName; this._metadata = new ChatClientMetadata("microsoft.foundry"); - TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + _ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } @@ -211,6 +211,7 @@ public override async Task GetResponseAsync(IEnumerable(null); var previous = ServedModelScope.Current; @@ -239,6 +240,7 @@ public override async IAsyncEnumerable GetStreamingResponseA var effectiveOptions = this._agentReference is not null ? this.GetAgentEnabledChatOptions(options) : options; + MarkRequestFeatures(effectiveOptions); var box = new StrongBox(null); var previous = ServedModelScope.Current; @@ -288,6 +290,7 @@ public async Task UploadFileAsync(string filePath, FileUploadPurpose // Use the Stream overload to honor cancellation; the (string, purpose) overload has no // CancellationToken parameter in the OpenAI SDK. using var stream = File.OpenRead(filePath); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient); var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false); return result.Value; } @@ -301,6 +304,7 @@ public async Task DeleteFileAsync(string fileId, Cancellatio { Throw.IfNullOrWhitespace(fileId); var fileClient = this.GetOpenAIFileClient(); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient); var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false); return result.Value; } @@ -373,6 +377,7 @@ public async Task CreateVectorStoreAsync(string name, IEnumerable DeleteVectorStoreAsync(string vecto { Throw.IfNullOrWhitespace(vectorStoreId); var vectorStoreClient = this.GetVectorStoreClient(); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient); var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false); return result.Value; } @@ -465,6 +471,25 @@ private VectorStoreClient GetVectorStoreClient() #endregion + private static void MarkRequestFeatures(ChatOptions? options) + { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient); + + if (options?.Tools is null) + { + return; + } + + foreach (AITool tool in options.Tools) + { + if (tool is HostedMcpToolboxAITool) + { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryToolbox); + return; + } + } + } + /// /// Parses an agent endpoint URI of shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai @@ -646,24 +671,6 @@ private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient( return new AgentEndpointInner(chatClient, aiProjectClient, agentName); } - /// Best-effort registration of via the MEAI hook with at-most-once dedup per pipeline. - private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient) - { -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - if (innerClient?.GetService() is { } policies) - { - // OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against - // the private _entries collection on the OpenAIRequestPolicies instance, so the - // policy is registered at most once even when many FoundryChatClient instances share - // the same underlying chat client. - OpenAIRequestPoliciesReflection.AddPolicyIfMissing( - policies, - AgentFrameworkUserAgentPolicy.Instance, - PipelinePosition.PerCall); - } -#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - } - /// /// Best-effort registration of via the MEAI /// hook. The policy captures the diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryFeatureUsage.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryFeatureUsage.cs new file mode 100644 index 00000000000..02efe3144b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryFeatureUsage.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry; + +internal static class FoundryFeatureUsage +{ + public static void MarkUsed(FeatureIndex feature) + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)feature); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs index ffae51cefc1..2995211f2d0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs @@ -94,6 +94,7 @@ public FoundryMemoryProvider( protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { Throw.IfNull(context); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory); State state = this._sessionState.GetOrInitializeState(context.Session); FoundryMemoryProviderScope scope = state.Scope; @@ -181,6 +182,8 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont /// protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory); + State state = this._sessionState.GetOrInitializeState(context.Session); FoundryMemoryProviderScope scope = state.Scope; @@ -251,6 +254,8 @@ protected override async ValueTask StoreAIContextAsync(InvokedContext context, C public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default) { Throw.IfNull(session); + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory); + State state = this._sessionState.GetOrInitializeState(session); FoundryMemoryProviderScope scope = state.Scope; @@ -292,6 +297,8 @@ public async Task EnsureMemoryStoreCreatedAsync( string? description = null, CancellationToken cancellationToken = default) { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory); + bool created = await this._client.CreateMemoryStoreIfNotExistsAsync( this._memoryStoreName, description, @@ -334,6 +341,8 @@ public async Task WhenUpdatesCompletedAsync( TimeSpan? pollingInterval = null, CancellationToken cancellationToken = default) { + FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory); + string? updateId = Volatile.Read(ref this._lastPendingUpdateId); if (updateId is null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index d2c529e3173..702d6a09ebc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -5,6 +5,7 @@ (ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects ships a stable 2.1.0. --> + true true diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/FeatureIndex.cs new file mode 100644 index 00000000000..4c8f6e47b9a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.GitHub.Copilot; + +internal enum FeatureIndex +{ + GitHubCopilot = 57, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.GitHubCopilot); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 768eea2e853..b584ce79837 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -172,6 +172,8 @@ protected override async IAsyncEnumerable RunCoreStreamingA $"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(GitHubCopilotAgentSession)}' can be used by this agent."); } + FeatureUsageMarker.MarkUsed(); + // Ensure the client is started await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Harness/FeatureIndex.cs new file mode 100644 index 00000000000..346e7c75618 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Harness/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +internal enum FeatureIndex +{ + CoreHarnessAgent = 1, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index 857ead5ca25..b7b7b7803f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -4,6 +4,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -130,6 +133,37 @@ public HarnessAgent(IChatClient chatClient, HarnessAgentOptions? options = null, { } + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreHarnessAgent); +#pragma warning restore MAAI001 + + return this.InnerAgent.RunAsync(messages, session, options, cancellationToken); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreHarnessAgent); +#pragma warning restore MAAI001 + + await foreach (AgentResponseUpdate update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + private static AIAgent BuildAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services) { ChatClientAgent innerAgent = BuildInnerAgent(chatClient, options, loggerFactory, services); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index 7bfd0db8dfe..04fdb4ba529 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -77,7 +77,9 @@ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuild // by any agent - returning a stub agent card here is safe. var stubAgentCard = new AgentCard { Name = "A2A Agent" }; - return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); + IEndpointConventionBuilder endpoint = endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); + MarkFeatureUsed(); + return endpoint; } /// @@ -133,6 +135,15 @@ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilde $"No A2AServer is registered for agent '{agentName}'. " + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); - return endpoints.MapA2A(a2aServer, path); + IEndpointConventionBuilder endpoint = endpoints.MapA2A(a2aServer, path); + MarkFeatureUsed(); + return endpoint; + } + + private static void MarkFeatureUsed() + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.HostingA2A); +#pragma warning restore MAAI001 } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/FeatureIndex.cs new file mode 100644 index 00000000000..21ab0fc3212 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.AspNetCore.Builder; + +internal enum FeatureIndex +{ + HostingA2A = 73, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 348d4a86625..64568c1a72e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -122,7 +122,7 @@ public static IEndpointConventionBuilder MapAGUIServer( var hostAgent = new AIHostAgent(aiAgent, agentSessionStore); - return endpoints.MapPost(pattern, async ( + IEndpointConventionBuilder endpoint = endpoints.MapPost(pattern, async ( [FromBody] RunAgentInput? input, [FromServices] IOptions jsonOptions, HttpContext context, @@ -170,6 +170,16 @@ public static IEndpointConventionBuilder MapAGUIServer( return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); #endif }); + + MarkFeatureUsed(); + return endpoint; + } + + private static void MarkFeatureUsed() + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.HostingAGUI); +#pragma warning restore MAAI001 } private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/FeatureIndex.cs new file mode 100644 index 00000000000..46ff6f33753 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +internal enum FeatureIndex +{ + HostingAGUI = 63, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 1e3f20f23fc..d2d0b1e7ca3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -71,6 +71,7 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions( => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false)) .WithName(endpointAgentName + "/CreateChatCompletion"); + MarkFeatureUsed(); return group; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs index 0c4af2cfb5d..e7565a0fcd6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Conversations.cs @@ -68,6 +68,7 @@ public static IEndpointConventionBuilder MapOpenAIConversations(this IEndpointRo .WithName("DeleteItem") .WithSummary("Delete a specific item"); + MarkFeatureUsed(); return group; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index aeacdcaa4c3..598725c9b0b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -106,6 +106,7 @@ public static IEndpointConventionBuilder MapOpenAIResponses( .WithName(endpointAgentName + "/ListResponseInputItems") .WithSummary("Lists the input items for a response"); + MarkFeatureUsed(); return group; } @@ -159,9 +160,17 @@ public static IEndpointConventionBuilder MapOpenAIResponses( .WithName("ListResponseInputItems") .WithSummary("Lists the input items for a response"); + MarkFeatureUsed(); return group; } + private static void MarkFeatureUsed() + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.HostingOpenAI); +#pragma warning restore MAAI001 + } + private static void ValidateAgentName([NotNull] string agentName) { var escaped = Uri.EscapeDataString(agentName); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/FeatureIndex.cs new file mode 100644 index 00000000000..fe45f80a795 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +internal enum FeatureIndex +{ + HostingOpenAI = 74, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index f2220e97ad8..ac54968cdce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -1,8 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -51,6 +54,7 @@ public ValueTask GetOrCreateSessionAsync(string conversationId, Ca { _ = Throw.IfNullOrWhitespace(conversationId); + MarkFeatureUsed(); return this._sessionStore.GetSessionAsync(this.InnerAgent, conversationId, cancellationToken); } @@ -68,6 +72,39 @@ public ValueTask SaveSessionAsync(string conversationId, AgentSession session, C _ = Throw.IfNullOrWhitespace(conversationId); _ = Throw.IfNull(session); + MarkFeatureUsed(); return this._sessionStore.SaveSessionAsync(this.InnerAgent, conversationId, session, cancellationToken); } + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + MarkFeatureUsed(); + return base.RunCoreAsync(messages, session, options, cancellationToken); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + MarkFeatureUsed(); + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private static void MarkFeatureUsed() + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.HostingAgent); +#pragma warning restore MAAI001 + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/FeatureIndex.cs new file mode 100644 index 00000000000..5ee7844ca34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting; + +internal enum FeatureIndex +{ + HostingAgent = 71, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FeatureIndex.cs new file mode 100644 index 00000000000..15e42a1e84e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hyperlight; + +internal enum FeatureIndex +{ + Hyperlight = 70, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.Hyperlight); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index 3b68ea095d7..d3442a32442 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -284,6 +284,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._toolRegistryVersion); } + FeatureUsageMarker.MarkUsed(); var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools); var description = InstructionBuilder.BuildExecuteCodeDescription( diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FeatureIndex.cs new file mode 100644 index 00000000000..b72b9145e56 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.LocalCodeAct; + +internal enum FeatureIndex +{ + LocalCodeAct = 72, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.LocalCodeAct); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs index dcf5532e9bd..9d36c5e5706 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -189,6 +189,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._tools.Values.ToList(), this._fileMounts.Values.ToList()); + FeatureUsageMarker.MarkUsed(); var description = InstructionBuilder.BuildExecuteCodeDescription(snapshot.Tools, snapshot.FileMounts); var executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/FeatureIndex.cs new file mode 100644 index 00000000000..253e21de0aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/FeatureIndex.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Mcp; + +internal enum FeatureIndex +{ + CoreMcp = 14, + CoreMcpSkillsSource = 19, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs index 9b681089f44..1c29ab138e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/McpClientTaskExtensions.cs @@ -43,6 +43,10 @@ public static async Task> ListAgentToolsWithTasksAsync { _ = Throw.IfNull(client); +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreMcp); +#pragma warning restore MAAI001 + McpTaskOptions effectiveOptions = options ?? new(); if (effectiveOptions.MaxConsecutiveStuckPolls <= 0) { diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs index 1496611cdb5..8ae78521ad0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs @@ -109,6 +109,10 @@ public AgentMcpSkillsSource(McpClient client, AgentMcpSkillsSourceOptions? optio /// public override async Task> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)Mcp.FeatureIndex.CoreMcpSkillsSource); +#pragma warning restore MAAI001 + McpSkillIndex? index = await this.TryReadIndexAsync(cancellationToken).ConfigureAwait(false); // Group entries by type and set aside those a registered loader can handle; entries of any diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/FeatureIndex.cs new file mode 100644 index 00000000000..d5fdd7a0719 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Mem0; + +internal enum FeatureIndex +{ + Mem0 = 60, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.Mem0); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs index 39c4db8c96a..8bae15ada2f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Client.cs @@ -62,6 +62,7 @@ public async Task> SearchAsync(string? applicationId, string }; using var content = new StringContent(JsonSerializer.Serialize(searchRequest, Mem0SourceGenerationContext.Default.SearchRequest), Encoding.UTF8, "application/json"); + FeatureUsageMarker.MarkUsed(); using var responseMessage = await this._httpClient.PostAsync(s_searchUri, content, cancellationToken).ConfigureAwait(false); responseMessage.EnsureSuccessStatusCode(); @@ -106,6 +107,7 @@ public async Task CreateMemoryAsync(string? applicationId, string? agentId, stri #pragma warning restore CA1308 using var content = new StringContent(JsonSerializer.Serialize(createMemoryRequest, Mem0SourceGenerationContext.Default.CreateMemoryRequest), Encoding.UTF8, "application/json"); + FeatureUsageMarker.MarkUsed(); using var responseMessage = await this._httpClient.PostAsync(s_createMemoryUri, content, cancellationToken).ConfigureAwait(false); responseMessage.EnsureSuccessStatusCode(); } @@ -123,6 +125,7 @@ public async Task ClearMemoryAsync(string? applicationId, string? agentId, strin var queryString = string.Join("&", querystringParams); var clearMemoryUrl = new Uri($"/v1/memories/?{queryString}", UriKind.Relative); + FeatureUsageMarker.MarkUsed(); using var responseMessage = await this._httpClient.DeleteAsync(clearMemoryUrl, cancellationToken).ConfigureAwait(false); responseMessage.EnsureSuccessStatusCode(); } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs index 34c07ea73f5..3fb8126b84d 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs @@ -25,6 +25,7 @@ public override async IAsyncEnumerable GetRawPagesAsync() protected override IAsyncEnumerable GetValuesFromPageAsync(ClientResult page) { + FeatureUsageMarker.MarkUsed(); var updates = ((ClientResult>)page).Value; return updates.AsChatResponseUpdatesAsync().AsOpenAIStreamingChatCompletionUpdatesAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingResponseUpdateCollectionResult.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingResponseUpdateCollectionResult.cs index a7e0aa5b670..45e332f67f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingResponseUpdateCollectionResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingResponseUpdateCollectionResult.cs @@ -24,8 +24,9 @@ public override async IAsyncEnumerable GetRawPagesAsync() yield return ClientResult.FromValue(this._updates, new StreamingUpdatePipelineResponse(this._updates)); } - protected async override IAsyncEnumerable GetValuesFromPageAsync(ClientResult page) + protected override async IAsyncEnumerable GetValuesFromPageAsync(ClientResult page) { + FeatureUsageMarker.MarkUsed(); var updates = ((ClientResult>)page).Value; await foreach (var update in updates.ConfigureAwait(false)) diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs index 5dc0b372ac1..d9b5eb648a2 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs @@ -44,6 +44,7 @@ public static async Task RunAsync(this AIAgent agent, IEnumerabl Throw.IfNull(agent); Throw.IfNull(messages); + FeatureUsageMarker.MarkUsed(); var response = await agent.RunAsync([.. messages.AsChatMessages()], session, options, cancellationToken).ConfigureAwait(false); return response.AsOpenAIChatCompletion(); @@ -96,6 +97,7 @@ public static async Task RunAsync(this AIAgent agent, IEnumerabl Throw.IfNull(agent); Throw.IfNull(messages); + FeatureUsageMarker.MarkUsed(); var response = await agent.RunAsync(messages.AsChatMessages(), session, options, cancellationToken).ConfigureAwait(false); return response.AsOpenAIResponse(); diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs index be3216083c8..d429f840ad7 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs @@ -77,12 +77,14 @@ public static ChatClientAgent AsAIAgent( Throw.IfNull(options); var chatClient = client.AsIChatClient(); + _ = Microsoft.Agents.AI.OpenAI.OpenAIUserAgentPolicies.Registration.TryRegister(chatClient); if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.OpenAI.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 642c0da2034..9d27e9cf033 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -89,12 +89,14 @@ public static ChatClientAgent AsAIAgent( Throw.IfNull(options); var chatClient = client.AsIChatClient(model); + _ = Microsoft.Agents.AI.OpenAI.OpenAIUserAgentPolicies.Registration.TryRegister(chatClient); if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + chatClient = new Microsoft.Agents.AI.OpenAI.FeatureUsageChatClient(chatClient); return new ChatClientAgent(chatClient, options, loggerFactory, services); } @@ -117,11 +119,14 @@ public static ChatClientAgent AsAIAgent( [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient, string? model = null, bool includeReasoningEncryptedContent = true) { - return Throw.IfNull(responseClient) - .AsIChatClient(model) + IChatClient chatClient = Throw.IfNull(responseClient).AsIChatClient(model); + _ = Microsoft.Agents.AI.OpenAI.OpenAIUserAgentPolicies.Registration.TryRegister(chatClient); + + return chatClient .AsBuilder() .ConfigureOptions(x => { + Microsoft.Agents.AI.OpenAI.FeatureUsageMarker.MarkUsed(); var previousFactory = x.RawRepresentationFactory; x.RawRepresentationFactory = state => { diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/FeatureIndex.cs new file mode 100644 index 00000000000..57d5cf1d901 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/FeatureIndex.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.OpenAI; + +internal enum FeatureIndex +{ + OpenAI = 54, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.OpenAI); +#pragma warning restore MAAI001 + } +} + +internal sealed class FeatureUsageChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) +{ + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + FeatureUsageMarker.MarkUsed(); + return base.GetResponseAsync(messages, options, cancellationToken); + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + FeatureUsageMarker.MarkUsed(); + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj index ed74c757c61..e9a664f5758 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj @@ -3,6 +3,7 @@ true enable + true true diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/UserAgentPolicies.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/UserAgentPolicies.cs new file mode 100644 index 00000000000..8b2c97edb79 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/UserAgentPolicies.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Internal; + +namespace Microsoft.Agents.AI.OpenAI; + +internal static class OpenAIUserAgentPolicies +{ + internal static AgentFrameworkUserAgentPolicyRegistration Registration { get; } = + new( + [ + "cognitiveservices.azure.com", + "openai.azure.com", + "services.ai.azure.com", + ], + BaseUserAgentScope.ApprovedOrigins); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Purview/FeatureIndex.cs new file mode 100644 index 00000000000..42c633a0029 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview; + +internal enum FeatureIndex +{ + Purview = 61, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.Purview); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs index 27882375d7c..f5c7477bd56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -66,6 +66,7 @@ private static string GetSessionIdFromAgentSession(AgentSession? session, IEnume /// The chat client's response. This could be the response from the chat client or a message indicating that Purview has blocked the prompt or response. public async Task ProcessChatContentAsync(IEnumerable messages, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) { + FeatureUsageMarker.MarkUsed(); string? resolvedUserId = null; try @@ -136,6 +137,7 @@ public async Task ProcessChatContentAsync(IEnumerable /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. public async Task ProcessAgentContentAsync(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { + FeatureUsageMarker.MarkUsed(); string? resolvedUserId = null; string sessionId = string.Empty; try diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/DockerShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/DockerShellExecutor.cs index 349179b98fd..a3b123e75b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/DockerShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/DockerShellExecutor.cs @@ -137,7 +137,7 @@ public DockerShellExecutor(DockerShellExecutorOptions options) this._pidsLimit = options.PidsLimit; this._user = options.User ?? ContainerUser.Default; this._readOnlyRoot = options.ReadOnlyRoot; - this._extraRunArgs = options.ExtraRunArgs ?? Array.Empty(); + this._extraRunArgs = options.ExtraRunArgs ?? []; this._env = options.Environment ?? new Dictionary(); this._policy = options.Policy ?? new ShellPolicy(); this._timeout = options.Timeout; @@ -161,6 +161,8 @@ public override async Task InitializeAsync(CancellationToken cancellationToken = { return; } + + FeatureUsageMarker.MarkUsed(); await this.StartContainerAsync(cancellationToken).ConfigureAwait(false); this._containerStarted = true; if (this._mode == ShellMode.Persistent) @@ -228,6 +230,7 @@ public override async Task RunAsync(string command, CancellationTok $"Command rejected by policy: {decision.Reason ?? "(unspecified)"}"); } + FeatureUsageMarker.MarkUsed(); if (this._mode == ShellMode.Persistent) { if (this._session is null) @@ -406,7 +409,7 @@ public static IReadOnlyList BuildRunArgv( } if (extraArgs is not null) { - foreach (var a in extraArgs) { argv.Add(a); } + argv.AddRange(extraArgs); } argv.Add(image); argv.Add("sleep"); @@ -422,7 +425,7 @@ public static IReadOnlyList BuildRunArgv( /// public static IReadOnlyList BuildExecArgv(string binary, string containerName) { - return new List { binary, "exec", "-i", containerName, "bash", "--noprofile", "--norc" }; + return [binary, "exec", "-i", containerName, "bash", "--noprofile", "--norc"]; } private async Task StartContainerAsync(CancellationToken cancellationToken) @@ -457,11 +460,13 @@ private async Task StopContainerAsync() private async Task RunStatelessAsync(string command, CancellationToken cancellationToken) { var perCallName = GenerateContainerName(); - var argv = new List(this.BuildRunArgvStateless(perCallName)); - argv.Add(this._image); - argv.Add("bash"); - argv.Add("-c"); - argv.Add(command); + var argv = new List(this.BuildRunArgvStateless(perCallName)) + { + this._image, + "bash", + "-c", + command + }; var stopwatch = Stopwatch.StartNew(); var stdoutBuf = new HeadTailBuffer(this._maxOutputBytes); @@ -558,7 +563,7 @@ private List BuildRunArgvStateless(string perCallName) argv.Add("-e"); argv.Add($"{kv.Key}={kv.Value}"); } - foreach (var a in this._extraRunArgs) { argv.Add(a); } + argv.AddRange(this._extraRunArgs); return argv; } @@ -568,7 +573,7 @@ private async Task BestEffortKillContainerAsync(string containerName) { using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); _ = await RunDockerCommandAsync( - new[] { this.DockerBinary, "kill", "--signal", "KILL", containerName }, killCts.Token).ConfigureAwait(false); + [this.DockerBinary, "kill", "--signal", "KILL", containerName], killCts.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException || ex is Win32Exception || ex is InvalidOperationException) { diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/FeatureIndex.cs new file mode 100644 index 00000000000..4bfbf598da7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Tools.Shell; + +internal enum FeatureIndex +{ + ToolsShell = 69, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.ToolsShell); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/LocalShellExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/LocalShellExecutor.cs index 51e973d0bf4..7268c6255c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/LocalShellExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/LocalShellExecutor.cs @@ -139,6 +139,7 @@ public override async Task RunAsync(string command, CancellationTok $"Command rejected by policy: {decision.Reason ?? "(unspecified)"}"); } + FeatureUsageMarker.MarkUsed(); return this._mode == ShellMode.Persistent ? await this.RunPersistentAsync(command, cancellationToken).ConfigureAwait(false) : await this.RunStatelessAsync(command, cancellationToken).ConfigureAwait(false); @@ -168,6 +169,8 @@ public override Task InitializeAsync(CancellationToken cancellationToken = defau { return Task.CompletedTask; } + + FeatureUsageMarker.MarkUsed(); ShellSession session; lock (this._sessionGate) { diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/FeatureIndex.cs new file mode 100644 index 00000000000..93b4800432b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/FeatureIndex.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Valkey; + +internal enum FeatureIndex +{ + Valkey = 59, +} + +internal static class FeatureUsageMarker +{ + public static void MarkUsed() + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + FeatureUsage.MarkUsed((int)FeatureIndex.Valkey); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs index 088d66ed471..b8cb0637f8b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs @@ -85,6 +85,7 @@ protected override async ValueTask> ProvideChatHistoryA Throw.IfNull(context); cancellationToken.ThrowIfCancellationRequested(); + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(context.Session); var db = this._connection.GetDatabase(); var key = this.BuildKey(state); @@ -146,6 +147,7 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, return; } + FeatureUsageMarker.MarkUsed(); var db = this._connection.GetDatabase(); var key = this.BuildKey(state); @@ -179,6 +181,7 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, public async Task ClearMessagesAsync(AgentSession? session, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(session); var db = this._connection.GetDatabase(); var key = this.BuildKey(state); @@ -194,6 +197,7 @@ public async Task ClearMessagesAsync(AgentSession? session, CancellationToken ca public async Task GetMessageCountAsync(AgentSession? session, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + FeatureUsageMarker.MarkUsed(); var state = this._sessionState.GetOrInitializeState(session); var db = this._connection.GetDatabase(); var key = this.BuildKey(state); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 646f23655bb..aeb3d2b6e95 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -84,7 +84,13 @@ public static Workflow Build( WorkflowElementWalker walker = new(visitor); walker.Visit(workflowElement); - return visitor.Complete(); + Workflow workflow = visitor.Complete(); + +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.DeclarativeWorkflow); +#pragma warning restore MAAI001 + + return workflow; } private static AdaptiveDialog ReadWorkflow(TextReader yamlReader) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/FeatureIndex.cs new file mode 100644 index 00000000000..dea25ec4a18 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/FeatureIndex.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +internal enum FeatureIndex +{ + DeclarativeWorkflow = 66, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs index feb31ddd9f1..3f9cd975ef9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs @@ -99,6 +99,6 @@ public Workflow Build() builder.WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]); }); - return builder.Build(); + return builder.BuildForFeature((int)FeatureIndex.OrchestrationConcurrent); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FeatureIndex.cs new file mode 100644 index 00000000000..166bf75a497 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FeatureIndex.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +internal enum FeatureIndex +{ + CoreWorkflow = 2, + OrchestrationSequential = 32, + OrchestrationConcurrent = 33, + OrchestrationGroupChat = 34, + OrchestrationMagentic = 35, + OrchestrationHandoff = 36, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs index 14167a2e8f6..b7084d55938 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -87,6 +87,6 @@ public Workflow Build() } }); - return builder.Build(); + return builder.BuildForFeature((int)FeatureIndex.OrchestrationGroupChat); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 906ebab8859..bb88adfef8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -466,11 +466,11 @@ ExecutorBinding CreateFactoryBinding(AIAgent agent) { if (!effectiveTargets.TryGetValue(agent, out HashSet? handoffs)) { - handoffs = new(); + handoffs = []; } // Use the ExecutorId as the placeholder id for a (possibly) future-bound factory - builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) => + builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), sb => { foreach (HandoffTarget handoff in handoffs) { @@ -478,7 +478,7 @@ ExecutorBinding CreateFactoryBinding(AIAgent agent) // turn falls through to the default branch, which routes to HandoffEndExecutor. string targetAgentId = handoff.Target.Id; sb.AddCase(state => state?.RequestedHandoffTargetAgentId == targetAgentId // Use AgentId for target matching - && state.IsTerminated != true, + && !state.IsTerminated, HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level } @@ -634,6 +634,6 @@ public Workflow Build() } }); - return builder.Build(); + return builder.BuildForFeature((int)FeatureIndex.OrchestrationHandoff); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs index ae5494896b3..35eb1328514 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs @@ -30,7 +30,7 @@ namespace Microsoft.Agents.AI.Workflows; /// public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase { - private readonly List _team = new(); + private readonly List _team = []; private int _maxStalls = TaskLimits.DefaultMaxStallCount; private int? _maxRounds; private int? _maxResets; @@ -201,7 +201,7 @@ public Workflow Build() "otherwise progress-ledger parsing and next-speaker routing would break."); } - return this.ReduceToWorkflowBuilder().Build(); + return this.ReduceToWorkflowBuilder().BuildForFeature((int)FeatureIndex.OrchestrationMagentic); } private TaskLimits Limits => new( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs index 04c847f4160..f6b1709b20c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs @@ -98,6 +98,6 @@ public Workflow Build() builder.WithIntermediateOutputFrom(agentExecutors); }); - return builder.Build(); + return builder.BuildForFeature((int)FeatureIndex.OrchestrationSequential); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 3acb0c3f612..1f6ae184054 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -670,6 +670,9 @@ private Workflow BuildInternal(bool validateOrphans, Activity? activity = null) /// Thrown if there are unbound executors in the workflow definition, /// or if the start executor is not bound. public Workflow Build(bool validateOrphans = true) + => this.BuildForFeature((int)FeatureIndex.CoreWorkflow, validateOrphans); + + internal Workflow BuildForFeature(int featureIndex, bool validateOrphans = true) { using Activity? activity = this._telemetryContext.StartWorkflowBuildActivity(); @@ -677,6 +680,10 @@ public Workflow Build(bool validateOrphans = true) activity?.AddEvent(new ActivityEvent(EventNames.BuildCompleted)); +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed(featureIndex); +#pragma warning restore MAAI001 + return workflow; } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 3b4d5cf9a6d..5345074e25c 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -208,6 +208,10 @@ protected override async Task RunCoreAsync( { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreAgent); +#pragma warning restore MAAI001 + (ChatClientAgentSession safeSession, ChatOptions? chatOptions, List inputMessagesForChatClient, @@ -294,6 +298,10 @@ protected override async IAsyncEnumerable RunCoreStreamingA { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreAgent); +#pragma warning restore MAAI001 + (ChatClientAgentSession safeSession, ChatOptions? chatOptions, List inputMessagesForChatClient, @@ -675,7 +683,7 @@ internal async Task NotifyProvidersOfFailureAsync( if (agentRunOptions?.AdditionalProperties is { Count: > 0 }) { chatOptions ??= new ChatOptions(); - chatOptions.AdditionalProperties ??= new(); + chatOptions.AdditionalProperties ??= []; foreach (var kvp in agentRunOptions.AdditionalProperties) { chatOptions.AdditionalProperties[kvp.Key] = kvp.Value; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 772a8b7c59f..2d919f709cf 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -112,6 +112,10 @@ public static async Task> CompactAsync(CompactionStrate /// protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreCompactionProvider); +#pragma warning restore MAAI001 + using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke); ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent); @@ -146,9 +150,9 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext // Treat all messages already in the index as chat history. foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) { - message.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + message.AdditionalProperties ??= []; message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = - new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!); + new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName); } // Update existing index with any new messages appended since the last call. @@ -188,9 +192,9 @@ await this._compactionStrategy.CompactAsync( // Only consider messages that aren't already marked as ChatHistory and messages that weren't passed into the provider. if (message.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory && !messageList.Any(x => x.ContentEquals(message))) { - message.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + message.AdditionalProperties ??= []; message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = - new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!); + new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName); } } diff --git a/dotnet/src/Microsoft.Agents.AI/FeatureIndex.cs b/dotnet/src/Microsoft.Agents.AI/FeatureIndex.cs new file mode 100644 index 00000000000..d946c081151 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/FeatureIndex.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +internal enum FeatureIndex +{ + CoreAgent = 0, + CoreToolApproval = 3, + CoreChatHistoryMemoryProvider = 4, + CoreFileMemoryProvider = 5, + CoreTextSearchProvider = 6, + CoreFileAccessProvider = 7, + CoreSkillsProvider = 8, + CoreCompactionProvider = 9, + CoreTodoProvider = 10, + CoreAgentModeProvider = 11, + CoreBackgroundAgentsProvider = 12, + CoreFileSkillsSource = 15, + CoreInMemorySkillsSource = 16, + CoreInlineSkill = 17, + CoreClassSkill = 18, +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 9f81f6c4e62..ac12296f995 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -232,6 +232,10 @@ public async Task SetModeAsync(AgentSession session, string mode, CancellationTo /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreAgentModeProvider); +#pragma warning restore MAAI001 + string currentMode; string? previousModeForNotification; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 5e7aa2ecc12..724d6ecae6c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -121,6 +121,10 @@ public BackgroundAgentsProvider(IEnumerable agents, BackgroundAgentsPro /// protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreBackgroundAgentsProvider); +#pragma warning restore MAAI001 + BackgroundAgentState state = this._sessionState.GetOrInitializeState(context.Session); BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs index 7674688113c..9c50cff4a68 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs @@ -244,6 +244,10 @@ public void Dispose() /// protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreFileAccessProvider); +#pragma warning restore MAAI001 + return new ValueTask(new AIContext { Instructions = this._instructions, @@ -429,7 +433,7 @@ private async Task> GrepAsync(string regexPattern, string string prefix = target; if (prefix.Length == 0) { - return new List(results); + return [.. results]; } var rerooted = new List(results.Count); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 0b41e82fba8..481089bd2c5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -127,6 +127,10 @@ public void Dispose() /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreFileMemoryProvider); +#pragma warning restore MAAI001 + FileMemoryState state = this._sessionState.GetOrInitializeState(context.Session); // Ensure the working folder exists in the store. @@ -448,8 +452,8 @@ private async Task RebuildMemoryIndexAsync(FileMemoryState state, CancellationTo .ToList(); var sb = new System.Text.StringBuilder(); - sb.AppendLine("# Memory Index"); - sb.AppendLine(); + sb.AppendLine("# Memory Index") + .AppendLine(); int count = 0; foreach (string file in sortedFiles) @@ -511,7 +515,7 @@ private static bool IsInternalFile(string fileName) => /// File memory is a flat namespace within the working folder, so nested names are rejected up front. /// private static bool IsNestedPath(string normalizedFileName) => - normalizedFileName.IndexOf('/') >= 0; + normalizedFileName.Contains('/'); /// /// Validates that a normalized memory file name is acceptable for write operations, diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index 1d24b81d6e8..018fbc939b8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -154,6 +154,10 @@ public async Task> GetRemainingTodosAsync(AgentSession session, C /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreTodoProvider); +#pragma warning restore MAAI001 + var aiContext = new AIContext { Instructions = this._instructions, diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs index 79a2a160d0d..50ae88bb818 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs @@ -114,6 +114,10 @@ protected override async Task RunCoreAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreToolApproval); +#pragma warning restore MAAI001 + var requestMessages = messages as IReadOnlyCollection ?? messages.ToList(); // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. @@ -191,6 +195,10 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreToolApproval); +#pragma warning restore MAAI001 + var requestMessages = messages as IReadOnlyCollection ?? messages.ToList(); // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. @@ -742,7 +750,7 @@ private static List UnwrapAlwaysApproveResponses( ToolApprovalState state, JsonSerializerOptions jsonSerializerOptions) { - var messageList = messages as IList ?? new List(messages); + var messageList = messages as IList ?? [.. messages]; var result = new List(messageList.Count); bool anyModified = false; diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index 2bc8408a26d..f8fd7abaa9e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -155,6 +155,10 @@ protected override async ValueTask ProvideAIContextAsync(AIContextPro { _ = Throw.IfNull(context); +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreChatHistoryMemoryProvider); +#pragma warning restore MAAI001 + var state = this._sessionState.GetOrInitializeState(context.Session); var searchScope = state.SearchScope; @@ -197,6 +201,10 @@ protected override ValueTask> InvokingCoreAsync(Invokin throw new InvalidOperationException($"Using the {nameof(ChatHistoryMemoryProvider)} as a {nameof(MessageAIContextProvider)} is not supported when {nameof(ChatHistoryMemoryProviderOptions.SearchTime)} is set to {ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling}."); } +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreChatHistoryMemoryProvider); +#pragma warning restore MAAI001 + return base.InvokingCoreAsync(context, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs index 94cec231ac2..6bc6f2624a2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentInMemorySkillsSource.cs @@ -27,6 +27,10 @@ public AgentInMemorySkillsSource(IEnumerable skills) /// public override Task> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInMemorySkillsSource); +#pragma warning restore MAAI001 + return Task.FromResult>(this._skills); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index 38e8015c1ca..200260cb3b1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -284,6 +284,10 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); } +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreSkillsProvider); +#pragma warning restore MAAI001 + return new AIContext { Instructions = this.BuildSkillsInstructions(skills), diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 14d5d501f1e..d28c969cb5e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -116,6 +116,10 @@ public AgentFileSkillsSource( /// public override Task> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreFileSkillsSource); +#pragma warning restore MAAI001 + var discoveredPaths = this.DiscoverSkillDirectories(this._skillPaths); LogSkillsDiscovered(this._logger, discoveredPaths.Count); @@ -622,7 +626,7 @@ private string[] SafeEnumerateDirectories(string path, FileAttributes attributes LogDirectoryInspectionFailed(this._logger, SanitizePathForLog(path)); } - return Array.Empty(); + return []; } } @@ -704,7 +708,7 @@ private static string NormalizePath(string path) path = path.TrimEnd('/', '\\'); // Normalize all separators to forward slashes - if (path.IndexOf('\\') >= 0) + if (path.Contains('\\')) { path = path.Replace('\\', '/'); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 24dedd50a26..ba6df3f7aff 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -145,7 +145,14 @@ protected AgentClassSkill(Func? argumentMarsh /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts. /// The result is cached after the first access. Override to provide custom content. /// - public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value); + public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) + { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreClassSkill); +#pragma warning restore MAAI001 + + return new(this._content.Value); + } /// /// Gets the resources associated with this skill, or if none. @@ -188,6 +195,10 @@ protected AgentClassSkill(Func? argumentMarsh /// public sealed override ValueTask GetResourceAsync(string name, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreClassSkill); +#pragma warning restore MAAI001 + var resource = this.Resources?.FirstOrDefault(r => r.Name == name); return new(resource); } @@ -195,6 +206,10 @@ protected AgentClassSkill(Func? argumentMarsh /// public sealed override ValueTask GetScriptAsync(string name, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreClassSkill); +#pragma warning restore MAAI001 + var script = this.Scripts?.FirstOrDefault(s => s.Name == name); return new(script); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index 4342779de85..aeb3ac20ad0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -104,12 +104,20 @@ public AgentInlineSkill( /// public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInlineSkill); +#pragma warning restore MAAI001 + return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts)); } /// public override ValueTask GetResourceAsync(string name, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInlineSkill); +#pragma warning restore MAAI001 + var resource = this._resources?.FirstOrDefault(r => r.Name == name); return new(resource); } @@ -117,6 +125,10 @@ public override ValueTask GetContentAsync(CancellationToken cancellation /// public override ValueTask GetScriptAsync(string name, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreInlineSkill); +#pragma warning restore MAAI001 + var script = this._scripts?.FirstOrDefault(s => s.Name == name); return new(script); } diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index 7a48243ef6b..72c94011313 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -109,6 +109,10 @@ public TextSearchProvider( /// protected override async ValueTask ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) { +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreTextSearchProvider); +#pragma warning restore MAAI001 + if (this._searchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke) { // Expose the search tool for on-demand invocation. @@ -136,6 +140,10 @@ protected override ValueTask> InvokingCoreAsync(Invokin throw new InvalidOperationException($"Using the {nameof(TextSearchProvider)} as a {nameof(MessageAIContextProvider)} is not supported when {nameof(TextSearchProviderOptions.SearchTime)} is set to {TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling}."); } +#pragma warning disable MAAI001 + FeatureUsage.MarkUsed((int)FeatureIndex.CoreTextSearchProvider); +#pragma warning restore MAAI001 + return base.InvokingCoreAsync(context, cancellationToken); } @@ -289,11 +297,11 @@ private string FormatResults(IList results) { sb.AppendLine($"SourceDocLink: {result.SourceLink}"); } - sb.AppendLine($"Contents: {result.Text}"); - sb.AppendLine("----"); + sb.AppendLine($"Contents: {result.Text}") + .AppendLine("----"); } - sb.AppendLine(this._citationsPrompt); - sb.AppendLine(); + sb.AppendLine(this._citationsPrompt) + .AppendLine(); return sb.ToString(); } diff --git a/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicy.cs b/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicy.cs new file mode 100644 index 00000000000..4a74da18f8c --- /dev/null +++ b/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicy.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Required in projects with implicit usings disabled. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +#pragma warning restore IDE0005 + +namespace Microsoft.Agents.AI.Internal; + +internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy +{ + private const string UserAgentHeader = "User-Agent"; + private readonly Func _isApprovedOrigin; + private readonly BaseUserAgentScope _scope; + private readonly string _segmentValue; + + internal AgentFrameworkUserAgentPolicy(Func isApprovedOrigin, BaseUserAgentScope scope) + { + this._isApprovedOrigin = isApprovedOrigin; + this._scope = scope; + this._segmentValue = CreateSegmentValue(); + } + + public override void Process( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + this.UpdateHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + this.UpdateHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private void UpdateHeader(PipelineMessage message) + { + if (this._scope == BaseUserAgentScope.ApprovedOrigins && + !this._isApprovedOrigin(message.Request.Uri)) + { + return; + } + + if (message.Request.Headers.TryGetValue(UserAgentHeader, out string? existing) && + !string.IsNullOrEmpty(existing)) + { + if (existing!.IndexOf(this._segmentValue, StringComparison.Ordinal) < 0) + { + message.Request.Headers.Set(UserAgentHeader, $"{existing} {this._segmentValue}"); + } + + return; + } + + message.Request.Headers.Set(UserAgentHeader, this._segmentValue); + } + + private static string CreateSegmentValue() + { + const string Name = "agent-framework-dotnet"; + + if (typeof(AgentFrameworkUserAgentPolicy).Assembly + .GetCustomAttribute()?.InformationalVersion is string version) + { + int metadataStart = version.IndexOf('+'); + if (metadataStart >= 0) + { + version = version.Substring(0, metadataStart); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } +} diff --git a/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicyRegistration.cs b/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicyRegistration.cs new file mode 100644 index 00000000000..3974492dd48 --- /dev/null +++ b/dotnet/src/Shared/FeatureUsage/AgentFrameworkUserAgentPolicyRegistration.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Required in projects with implicit usings disabled. + +using System; +using System.ClientModel.Primitives; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +#pragma warning restore IDE0005 + +namespace Microsoft.Agents.AI.Internal; + +#pragma warning disable MEAI001 + +internal sealed class AgentFrameworkUserAgentPolicyRegistration +{ + // Linked-source consumers intentionally maintain independent registration state for their own wrapper pipelines. + private readonly string[] _approvedHostSuffixes; + private readonly ConditionalWeakTable _registrations = new(); + private readonly object _registrationLock = new(); + + internal AgentFrameworkUserAgentPolicyRegistration( + string[] approvedHostSuffixes, + BaseUserAgentScope baseUserAgentScope) + { + this._approvedHostSuffixes = approvedHostSuffixes is null + ? throw new ArgumentNullException(nameof(approvedHostSuffixes)) + : (string[])approvedHostSuffixes.Clone(); + this.BaseUserAgentPolicy = new(this.IsApprovedOrigin, baseUserAgentScope); +#pragma warning disable MAAI001 + this.FeatureUsagePolicy = new(this.IsApprovedOrigin, FeatureUsage.ApplyToUserAgent); +#pragma warning restore MAAI001 + } + + internal AgentFrameworkUserAgentPolicy BaseUserAgentPolicy { get; } + + internal FeatureUsageUserAgentPolicy FeatureUsagePolicy { get; } + + internal bool IsApprovedOrigin(Uri? uri) + { + if (uri?.IsAbsoluteUri != true || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string host = uri.IdnHost.TrimEnd('.'); + foreach (string suffix in this._approvedHostSuffixes) + { + if (string.Equals(host, suffix, StringComparison.OrdinalIgnoreCase) || + host.EndsWith($".{suffix}", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + internal bool TryRegister(IChatClient? chatClient) + { + return chatClient?.GetService() is { } policies && + this.TryRegister(policies); + } + + internal bool TryRegister(OpenAIRequestPolicies policies) + { + lock (this._registrationLock) + { + if (this._registrations.TryGetValue(policies, out _)) + { + return false; + } + + policies.AddPolicy(this.BaseUserAgentPolicy, PipelinePosition.PerCall); + policies.AddPolicy(this.FeatureUsagePolicy, PipelinePosition.BeforeTransport); + this._registrations.Add(policies, new RegistrationMarker()); + return true; + } + } + + private sealed class RegistrationMarker; +} + +#pragma warning restore MEAI001 diff --git a/dotnet/src/Shared/FeatureUsage/BaseUserAgentScope.cs b/dotnet/src/Shared/FeatureUsage/BaseUserAgentScope.cs new file mode 100644 index 00000000000..b2b5259e97a --- /dev/null +++ b/dotnet/src/Shared/FeatureUsage/BaseUserAgentScope.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Internal; + +internal enum BaseUserAgentScope +{ + AllRequests, + ApprovedOrigins, +} diff --git a/dotnet/src/Shared/FeatureUsage/FeatureUsageUserAgentPolicy.cs b/dotnet/src/Shared/FeatureUsage/FeatureUsageUserAgentPolicy.cs new file mode 100644 index 00000000000..2e6ec47cfae --- /dev/null +++ b/dotnet/src/Shared/FeatureUsage/FeatureUsageUserAgentPolicy.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Required in projects with implicit usings disabled. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading.Tasks; + +#pragma warning restore IDE0005 + +namespace Microsoft.Agents.AI.Internal; + +internal sealed class FeatureUsageUserAgentPolicy : PipelinePolicy +{ + private const string UserAgentHeader = "User-Agent"; + private readonly Func _isApprovedOrigin; + private readonly Func _applyToUserAgent; + + internal FeatureUsageUserAgentPolicy( + Func isApprovedOrigin, + Func applyToUserAgent) + { + this._isApprovedOrigin = isApprovedOrigin; + this._applyToUserAgent = applyToUserAgent; + } + + public override void Process( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + this.UpdateHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync( + PipelineMessage message, + IReadOnlyList pipeline, + int currentIndex) + { + this.UpdateHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private void UpdateHeader(PipelineMessage message) + { + bool hadHeader = message.Request.Headers.TryGetValue(UserAgentHeader, out string? current); + current ??= string.Empty; + + string updated = this._applyToUserAgent(current, this._isApprovedOrigin(message.Request.Uri)); + if (string.Equals(current, updated, StringComparison.Ordinal)) + { + return; + } + + if (updated.Length > 0) + { + message.Request.Headers.Set(UserAgentHeader, updated); + } + else if (hadHeader) + { + message.Request.Headers.Remove(UserAgentHeader); + } + } +} diff --git a/dotnet/tests/FeatureUsageAssert.cs b/dotnet/tests/FeatureUsageAssert.cs new file mode 100644 index 00000000000..33988da2d11 --- /dev/null +++ b/dotnet/tests/FeatureUsageAssert.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; +using System.Numerics; +using System.Reflection; + +namespace Microsoft.Agents.AI.Testing; + +[CollectionDefinition(nameof(FeatureUsageTestGroup), DisableParallelization = true)] +public sealed class FeatureUsageTestGroup; + +internal static class FeatureUsageAssert +{ + public static void Reset() + { + MethodInfo? reset = typeof(FeatureUsage).GetMethod( + "ResetStateForTests", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(reset); + reset.Invoke(obj: null, parameters: null); + } + + public static void Marked(int index) + { +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + string maskText = userAgent.Substring(Prefix.Length, userAgent.Length - Prefix.Length - 1); + BigInteger mask = BigInteger.Parse($"0{maskText}", NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + Assert.NotEqual(BigInteger.Zero, mask & (BigInteger.One << index)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..14fab0372f0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Moq; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() => FeatureUsageAssert.Reset(); + + [Fact] + public async Task RunAsync_ActivatesA2AAsync() + { + // Arrange + A2AAgent agent = CreateAgent(); + + // Act + _ = await agent.RunAsync("hello"); + + // Assert + FeatureUsageAssert.Marked(62); + } + + [Fact] + public async Task RunStreamingAsync_IsColdAndActivatesA2AAsync() + { + // Arrange + A2AAgent agent = CreateAgent(); + + // Act + IAsyncEnumerable stream = agent.RunStreamingAsync("hello"); + + // Assert + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(); + Assert.False(await enumerator.MoveNextAsync()); + FeatureUsageAssert.Marked(62); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private static A2AAgent CreateAgent() + { + Mock client = new(); + client + .Setup(instance => instance.SendMessageAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SendMessageResponse + { + Message = new Message { MessageId = "response", Role = Role.Agent } + }); + client + .Setup(instance => instance.SendStreamingMessageAsync( + It.IsAny(), + It.IsAny())) + .Returns(EmptyStreamAsync()); + + return new A2AAgent(client.Object); + } + + private static async IAsyncEnumerable EmptyStreamAsync() + { + await Task.CompletedTask; + yield break; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index 97541f6a942..a39fdda3d53 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -7,5 +7,11 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/FeatureUsageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/FeatureUsageTests.cs new file mode 100644 index 00000000000..328d49a305a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/FeatureUsageTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Moq; + +#pragma warning disable MAAI001 + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed partial class FeatureUsageTests : IDisposable +{ + private const string FeatureMaskDisabledEnvironmentVariable = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"; + + private readonly string? _originalDisabledValue; + + public FeatureUsageTests() + { + this._originalDisabledValue = Environment.GetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable); + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, null); + FeatureUsage.ResetStateForTests(); + } + + [Theory] + [InlineData(0, "v1.1")] + [InlineData(63, "v1.8000000000000000")] + [InlineData(64, "v1.10000000000000000")] + [InlineData(127, "v1.80000000000000000000000000000000")] + public void MarkUsed_BoundaryIndex_ProducesExpectedToken(int index, string expected) + { + // Arrange + + // Act + FeatureUsage.MarkUsed(index); + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.Equal(expected, token); + } + + [Theory] + [InlineData(-1)] + [InlineData(128)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public void MarkUsed_InvalidIndex_ThrowsArgumentOutOfRangeException(int index) + { + // Arrange + + // Act + ArgumentOutOfRangeException exception = Assert.Throws( + () => FeatureUsage.MarkUsed(index)); + + // Assert + Assert.Equal("index", exception.ParamName); + } + + [Fact] + public void MarkUsed_AllBitsConcurrently_AccumulatesEveryBit() + { + // Arrange + int[] indexes = new int[128]; + for (int index = 0; index < indexes.Length; index++) + { + indexes[index] = index; + } + + // Act + Parallel.ForEach(indexes, FeatureUsage.MarkUsed); + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.Equal($"v1.{new string('f', 32)}", token); + } + + [Fact] + public void MarkUsed_DuplicateIndex_DoesNotChangeToken() + { + // Arrange + FeatureUsage.MarkUsed(42); + string? originalToken = FeatureUsage.GetToken(); + + // Act + Parallel.For(0, 100, _ => FeatureUsage.MarkUsed(42)); + string? duplicateToken = FeatureUsage.GetToken(); + + // Assert + Assert.Equal("v1.40000000000", duplicateToken); + Assert.Same(originalToken, duplicateToken); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("TrUe")] + [InlineData("1")] + public void MarkUsed_Disabled_DoesNotAccumulate(string disabledValue) + { + // Arrange + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, disabledValue); + FeatureUsage.ResetStateForTests(); + + // Act + FeatureUsage.MarkUsed(0); + FeatureUsage.MarkUsed(127); + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, null); + FeatureUsage.ReloadDisabledStateForTests(); + + // Assert + Assert.Null(FeatureUsage.GetToken()); + } + + [Fact] + public void MarkUsed_Disabled_DoesNotValidateIndex() + { + // Arrange + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, "true"); + FeatureUsage.ResetStateForTests(); + + // Act + Exception? exception = Record.Exception(() => FeatureUsage.MarkUsed(128)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public async Task InMemoryChatHistoryProvider_ActivatesOnlyWhenParticipatingAsync() + { + // Arrange + var provider = new InMemoryChatHistoryProvider(); + var agent = new Mock().Object; + var session = new Mock().Object; + var context = new ChatHistoryProvider.InvokingContext(agent, session, []); + Assert.Null(FeatureUsage.GetToken()); + + // Act + _ = await provider.InvokingAsync(context); + + // Assert + Assert.Equal("v1.2000", FeatureUsage.GetToken()); + } + + [Fact] + public void Configuration_IsCachedUntilRefreshedForTests() + { + // Arrange + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, null); + FeatureUsage.ResetStateForTests(); + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, "true"); + + // Act + FeatureUsage.MarkUsed(7); + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.Equal("v1.80", token); + } + + [Fact] + public void PublicSurface_IsHiddenAndExperimental() + { + // Arrange + Type featureUsageType = typeof(FeatureUsage); + MethodInfo? markUsedMethod = featureUsageType.GetMethod(nameof(FeatureUsage.MarkUsed)); + MethodInfo? applyMethod = featureUsageType.GetMethod(nameof(FeatureUsage.ApplyToUserAgent)); + + // Act + EditorBrowsableAttribute? editorBrowsable = featureUsageType.GetCustomAttribute(); + ExperimentalAttribute? experimental = featureUsageType.GetCustomAttribute(); + + // Assert + Assert.NotNull(markUsedMethod); + Assert.NotNull(applyMethod); + Assert.Equal(EditorBrowsableState.Never, editorBrowsable?.State); + Assert.Equal("MAAI001", experimental?.DiagnosticId); + } + + [Fact] + public void GetToken_UsesLowercaseVersionedHexFormat() + { + // Arrange + FeatureUsage.MarkUsed(1); + FeatureUsage.MarkUsed(63); + FeatureUsage.MarkUsed(64); + FeatureUsage.MarkUsed(127); + + // Act + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.NotNull(token); + Assert.Matches(new Regex("^v1\\.[0-9a-f]{1,32}$", RegexOptions.CultureInvariant), token); + } + + [Fact] + public void GetToken_UnchangedMask_ReturnsCachedTokenInstance() + { + // Arrange + FeatureUsage.MarkUsed(12); + string? originalToken = FeatureUsage.GetToken(); + + // Act + string? cachedToken = FeatureUsage.GetToken(); + FeatureUsage.MarkUsed(12); + string? deduplicatedToken = FeatureUsage.GetToken(); + FeatureUsage.MarkUsed(13); + string? changedToken = FeatureUsage.GetToken(); + + // Assert + Assert.Same(originalToken, cachedToken); + Assert.Same(originalToken, deduplicatedToken); + Assert.NotSame(originalToken, changedToken); + Assert.Equal("v1.3000", changedToken); + Assert.Equal("v1.1000", originalToken); + } + + [Fact] + public void GetToken_BitsInBothLanes_PadsLowLane() + { + // Arrange + FeatureUsage.MarkUsed(1); + FeatureUsage.MarkUsed(65); + + // Act + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.Equal("v1.20000000000000002", token); + } + + [Fact] + public void GetToken_EmptyMask_ReturnsNull() + { + // Arrange + + // Act + string? token = FeatureUsage.GetToken(); + + // Assert + Assert.Null(token); + } + + [Theory] + [InlineData("", "")] + [InlineData("app/1.0", "app/1.0")] + [InlineData(" app/1.0 ", " app/1.0 ")] + [InlineData("app/1.0 (custom=value)", "app/1.0 (custom=value)")] + [InlineData("app/1.0 (feat=v1.)", "app/1.0 (feat=v1.)")] + [InlineData("app/1.0 (feat=vx.1)", "app/1.0 (feat=vx.1)")] + [InlineData("app/1.0 (feat=v1.1g)", "app/1.0 (feat=v1.1g)")] + [InlineData("app/1.0(feat=v1.1)", "app/1.0(feat=v1.1)")] + [InlineData("app/1.0 (feat=v1.1)suffix", "app/1.0 (feat=v1.1)suffix")] + public void ApplyToUserAgent_NoToken_PreservesHeaderWithoutValidFeatureCommentByteForByte( + string userAgent, + string expected) + { + // Arrange + + // Act + string actual = FeatureUsage.ApplyToUserAgent(userAgent); + + // Assert + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("app/1.0 (feat=v1.1)", "app/1.0")] + [InlineData("(feat=v1.1)", "")] + [InlineData("(feat=v1.1) app/1.0", "app/1.0")] + [InlineData("app/1.0 (feat=v2.AB)", "app/1.0")] + [InlineData("app/1.0 (feat=v1.1) (feat=v2.2)", "app/1.0")] + [InlineData("app/1.0 (feat=v1.1)", "app/1.0 ")] + public void ApplyToUserAgent_Excluded_StripsOnlyValidFeatureComments(string userAgent, string expected) + { + // Arrange + + // Act + string actual = FeatureUsage.ApplyToUserAgent(userAgent, includeFeatureToken: false); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void ApplyToUserAgent_RefreshesStaleComment_PreservesUnrelatedBytes_AndIsIdempotent() + { + // Arrange + const string Original = "vendor/2.0 (custom=a) app/1.0 (feat=v1.1)"; + FeatureUsage.MarkUsed(5); + + // Act + string refreshed = FeatureUsage.ApplyToUserAgent(Original); + string repeated = FeatureUsage.ApplyToUserAgent(refreshed); + + // Assert + Assert.Equal("vendor/2.0 (custom=a) app/1.0 (feat=v1.20)", refreshed); + Assert.Equal(refreshed, repeated); + } + + [Fact] + public void ApplyToUserAgent_NullUserAgent_ThrowsArgumentNullException() + { + // Arrange / Act + ArgumentNullException exception = Assert.Throws( + () => FeatureUsage.ApplyToUserAgent(null!)); + + // Assert + Assert.Equal("userAgent", exception.ParamName); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, this._originalDisabledValue); + FeatureUsage.ResetStateForTests(); + } + + [CollectionDefinition(nameof(FeatureUsageTestGroup), DisableParallelization = true)] + public sealed class FeatureUsageTestGroup; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..b0299a40beb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Anthropic; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Anthropic.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + [Fact] + public async Task NonStreamingExecution_MarksAnthropicFeatureUsageAsync() + { + // Arrange + Mock innerClient = new(); + innerClient + .Setup(client => client.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + var agent = Mock.Of().AsAIAgent( + model: "test-model", + clientFactory: _ => innerClient.Object); + FeatureUsageAssert.Reset(); + + // Act + _ = await agent.RunAsync("hello"); + + // Assert + FeatureUsageAssert.Marked(55); + } + + [Fact] + public async Task StreamingExecution_IsColdAndMarksAnthropicFeatureUsageAsync() + { + // Arrange + Mock innerClient = new(); + innerClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(EmptyUpdatesAsync()); + var agent = Mock.Of().AsAIAgent( + model: "test-model", + clientFactory: _ => innerClient.Object); + FeatureUsageAssert.Reset(); + + // Act + IAsyncEnumerable updates = agent.RunStreamingAsync("hello"); + + // Assert + innerClient.Verify( + client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); + + await using IAsyncEnumerator enumerator = updates.GetAsyncEnumerator(); + Assert.False(await enumerator.MoveNextAsync()); + innerClient.Verify( + client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + FeatureUsageAssert.Marked(55); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private static async IAsyncEnumerable EmptyUpdatesAsync() + { + await Task.CompletedTask; + yield break; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj index 291c56f8792..52efe4a409c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj @@ -8,4 +8,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs index 301b58bc493..9d456b9ec5c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosCheckpointStoreTests.cs @@ -188,6 +188,7 @@ public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync() using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId); var sessionId = Guid.NewGuid().ToString(); var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test checkpoint" }, s_jsonOptions); + FeatureUsageAssert.Reset(); // Act var checkpointInfo = await store.CreateCheckpointAsync(sessionId, checkpointValue); @@ -197,6 +198,7 @@ public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync() Assert.Equal(sessionId, checkpointInfo.SessionId); Assert.NotNull(checkpointInfo.CheckpointId); Assert.NotEmpty(checkpointInfo.CheckpointId); + FeatureUsageAssert.Marked(58); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj index 0103c23028b..9a84af6e67a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj @@ -19,4 +19,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..fe04c3206bb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Declarative.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests +{ + [Fact] + public async Task ChatClientPromptAgentFactory_MarksFeatureUsageAsync() + { + // Arrange + var factory = new ChatClientPromptAgentFactory(new Mock().Object); + var promptAgent = PromptAgents.CreateTestPromptAgent(); + FeatureUsageAssert.Reset(); + + // Act + _ = await factory.TryCreateAsync(promptAgent); + + // Assert + FeatureUsageAssert.Marked(65); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj index 899dad4dca2..716ceb5c0a9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj @@ -14,4 +14,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..9b43a5617b1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.DevUI.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests +{ + [Fact] + public void MapDevUI_MarksFeatureUsage() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDevUI(); + using var app = builder.Build(); + FeatureUsageAssert.Reset(); + + // Act + app.MapDevUI(); + + // Assert + FeatureUsageAssert.Marked(64); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj index 7c0113faab1..ac1d045b003 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj @@ -14,4 +14,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/FeatureRegistryTests.cs b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/FeatureRegistryTests.cs new file mode 100644 index 00000000000..1b671a0508b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/FeatureRegistryTests.cs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.Agents.AI.FeatureRegistry.UnitTests; + +/// +/// Validates the package-local .NET v1 feature index declarations against the Markdown registry. +/// +public sealed class FeatureRegistryTests +{ + private const string DotNetTableHeading = "## Index table — .NET (`agent-framework-dotnet`, version 1)"; + private static readonly Dictionary s_externalRegistryEntries = + new() + { + [67] = "durabletask", + [68] = "azurefunctions", + }; + + private static readonly Dictionary s_expectedOwnersById = + new(StringComparer.OrdinalIgnoreCase) + { + ["core.agent"] = "Microsoft.Agents.AI", + ["core.harness_agent"] = "Microsoft.Agents.AI.Harness", + ["core.workflow"] = "Microsoft.Agents.AI.Workflows", + ["core.tool_approval"] = "Microsoft.Agents.AI", + ["core.chat_history_memory_provider"] = "Microsoft.Agents.AI", + ["core.file_memory_provider"] = "Microsoft.Agents.AI", + ["core.text_search_provider"] = "Microsoft.Agents.AI", + ["core.file_access_provider"] = "Microsoft.Agents.AI", + ["core.skills_provider"] = "Microsoft.Agents.AI", + ["core.compaction_provider"] = "Microsoft.Agents.AI", + ["core.todo_provider"] = "Microsoft.Agents.AI", + ["core.agent_mode_provider"] = "Microsoft.Agents.AI", + ["core.background_agents_provider"] = "Microsoft.Agents.AI", + ["core.in_memory_history_provider"] = "Microsoft.Agents.AI.Abstractions", + ["core.mcp"] = "Microsoft.Agents.AI.Mcp", + ["core.file_skills_source"] = "Microsoft.Agents.AI", + ["core.in_memory_skills_source"] = "Microsoft.Agents.AI", + ["core.inline_skill"] = "Microsoft.Agents.AI", + ["core.class_skill"] = "Microsoft.Agents.AI", + ["core.mcp_skills_source"] = "Microsoft.Agents.AI.Mcp", + ["orchestration.sequential"] = "Microsoft.Agents.AI.Workflows", + ["orchestration.concurrent"] = "Microsoft.Agents.AI.Workflows", + ["orchestration.group_chat"] = "Microsoft.Agents.AI.Workflows", + ["orchestration.magentic"] = "Microsoft.Agents.AI.Workflows", + ["orchestration.handoff"] = "Microsoft.Agents.AI.Workflows", + ["foundry.chat_client"] = "Microsoft.Agents.AI.Foundry", + ["foundry.agent"] = "Microsoft.Agents.AI.Foundry", + ["foundry.memory"] = "Microsoft.Agents.AI.Foundry", + ["foundry.evals"] = "Microsoft.Agents.AI.Foundry", + ["foundry.toolbox"] = "Microsoft.Agents.AI.Foundry", + ["foundry_hosting"] = "Microsoft.Agents.AI.Foundry.Hosting", + ["openai"] = "Microsoft.Agents.AI.OpenAI", + ["anthropic"] = "Microsoft.Agents.AI.Anthropic", + ["copilotstudio"] = "Microsoft.Agents.AI.CopilotStudio", + ["github_copilot"] = "Microsoft.Agents.AI.GitHub.Copilot", + ["azure_cosmos"] = "Microsoft.Agents.AI.CosmosNoSql", + ["valkey"] = "Microsoft.Agents.AI.Valkey", + ["mem0"] = "Microsoft.Agents.AI.Mem0", + ["purview"] = "Microsoft.Agents.AI.Purview", + ["a2a"] = "Microsoft.Agents.AI.A2A", + ["hosting.ag_ui"] = "Microsoft.Agents.AI.Hosting.AGUI.AspNetCore", + ["devui"] = "Microsoft.Agents.AI.DevUI", + ["declarative.agent"] = "Microsoft.Agents.AI.Declarative", + ["declarative.workflow"] = "Microsoft.Agents.AI.Workflows.Declarative", + ["tools.shell"] = "Microsoft.Agents.AI.Tools.Shell", + ["hyperlight"] = "Microsoft.Agents.AI.Hyperlight", + ["hosting.agent"] = "Microsoft.Agents.AI.Hosting", + ["local_codeact"] = "Microsoft.Agents.AI.LocalCodeAct", + ["hosting.a2a"] = "Microsoft.Agents.AI.Hosting.A2A.AspNetCore", + ["hosting.openai"] = "Microsoft.Agents.AI.Hosting.OpenAI", + }; + + /// + /// Ensures the .NET v1 registry and all in-repository package-local declarations have exact parity. + /// + [Fact] + public void DotNetV1DeclarationsMatchRegistry() + { + // Arrange + string repositoryRoot = FindRepositoryRoot(); + List registry = ReadDotNetV1Registry(repositoryRoot); + var parsingErrors = new List(); + List declarations = ReadFeatureDeclarations(repositoryRoot, parsingErrors); + + // Act + List validationErrors = ValidateRegistry(registry, declarations, parsingErrors); + + // Assert + AssertNoErrors(validationErrors); + } + + /// + /// Ensures registry/member matching remains insensitive to acronym casing while preserving ordinal semantics. + /// + [Fact] + public void RegistryKeyNormalizationIsAcronymSafe() + { + // Arrange + (string RegistryId, string MemberName)[] examples = + [ + ("hosting.ag_ui", "HostingAGUI"), + ("github_copilot", "GitHubCopilot"), + ("a2a", "A2A"), + ]; + + // Act + bool allMatch = examples.All( + example => StringComparer.OrdinalIgnoreCase.Equals( + NormalizeRegistryKey(example.RegistryId), + NormalizeRegistryKey(example.MemberName))); + + // Assert + Assert.True(allMatch); + } + + /// + /// Ensures every declaration is referenced from its owning package's activation code. + /// + /// + /// This prevents declaration parity from being misreported as activation coverage. + /// + [Fact] + public void FeatureIndexesHaveActivationReferences() + { + // Arrange + string repositoryRoot = FindRepositoryRoot(); + var parsingErrors = new List(); + List declarations = ReadFeatureDeclarations(repositoryRoot, parsingErrors); + + // Act + string[] missingReferences = FindMissingActivationReferences(repositoryRoot, declarations); + + // Assert + AssertNoErrors(parsingErrors.Concat(missingReferences).ToArray()); + } + + private static List ValidateRegistry( + List registry, + List declarations, + List parsingErrors) + { + var errors = new List(parsingErrors); + + foreach (RegistryEntry entry in registry.Where(entry => entry.Index is < 0 or > 127)) + { + errors.Add($"Registry entry '{entry.Id}' has out-of-range index {entry.Index}."); + } + + foreach (IGrouping overlap in registry.GroupBy(entry => entry.Index).Where(group => group.Count() > 1)) + { + errors.Add($"Registry index {overlap.Key} is assigned to: {string.Join(", ", overlap.Select(entry => entry.Id))}."); + } + + foreach (IGrouping duplicateKey in registry + .GroupBy(entry => NormalizeRegistryKey(entry.Id), StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1)) + { + errors.Add($"Normalized registry key '{duplicateKey.Key}' is not unique: {string.Join(", ", duplicateKey.Select(entry => entry.Id))}."); + } + + Dictionary registryByIndex = registry + .GroupBy(entry => entry.Index) + .Where(group => group.Count() == 1) + .ToDictionary(group => group.Key, group => group.Single()); + + ValidateExternalEntries(registryByIndex, declarations, errors); + ValidateOwnershipMap(registry, errors); + + foreach (FeatureDeclaration declaration in declarations) + { + if (declaration.Index is < 0 or > 127) + { + errors.Add($"{declaration.FilePath}: '{declaration.MemberName}' has out-of-range index {declaration.Index}."); + continue; + } + + if (!registryByIndex.TryGetValue(declaration.Index, out RegistryEntry? registryEntry)) + { + errors.Add($"{declaration.FilePath}: '{declaration.MemberName}' uses unassigned index {declaration.Index}."); + continue; + } + + if (!StringComparer.OrdinalIgnoreCase.Equals( + NormalizeRegistryKey(declaration.MemberName), + NormalizeRegistryKey(registryEntry.Id))) + { + errors.Add( + $"{declaration.FilePath}: index {declaration.Index} declares '{declaration.MemberName}', " + + $"but the registry id is '{registryEntry.Id}'."); + } + + if (s_expectedOwnersById.TryGetValue(registryEntry.Id, out string? expectedOwner) && + !StringComparer.Ordinal.Equals(declaration.Owner, expectedOwner)) + { + errors.Add( + $"Registry id '{registryEntry.Id}' ({declaration.Index}) is declared by '{declaration.Owner}', " + + $"but ownership is assigned to '{expectedOwner}'."); + } + } + + foreach (IGrouping overlap in declarations + .GroupBy(declaration => declaration.Index) + .Where(group => group.Count() > 1)) + { + errors.Add( + $"Feature index {overlap.Key} overlaps across declarations: " + + string.Join(", ", overlap.Select(declaration => $"{declaration.Owner}.{declaration.MemberName}")) + "."); + } + + foreach (RegistryEntry entry in registry.Where(entry => !s_externalRegistryEntries.ContainsKey(entry.Index))) + { + FeatureDeclaration[] matches = declarations + .Where(declaration => + declaration.Index == entry.Index && + StringComparer.OrdinalIgnoreCase.Equals( + NormalizeRegistryKey(declaration.MemberName), + NormalizeRegistryKey(entry.Id))) + .ToArray(); + + if (matches.Length != 1) + { + errors.Add($"Registry id '{entry.Id}' ({entry.Index}) has {matches.Length} matching in-repository declarations; expected 1."); + } + } + + return errors; + } + + private static void ValidateExternalEntries( + Dictionary registryByIndex, + List declarations, + List errors) + { + foreach ((int index, string id) in s_externalRegistryEntries) + { + if (!registryByIndex.TryGetValue(index, out RegistryEntry? entry) || + !StringComparer.Ordinal.Equals(entry.Id, id)) + { + errors.Add($"External registry exception {index} must remain assigned to '{id}'."); + } + + if (declarations.Any(declaration => declaration.Index == index)) + { + errors.Add($"External registry exception '{id}' ({index}) must not have an in-repository declaration."); + } + } + } + + private static void ValidateOwnershipMap(List registry, List errors) + { + RegistryEntry[] localEntries = registry + .Where(entry => !s_externalRegistryEntries.ContainsKey(entry.Index)) + .ToArray(); + + foreach (RegistryEntry entry in localEntries.Where(entry => !s_expectedOwnersById.ContainsKey(entry.Id))) + { + errors.Add($"Registry id '{entry.Id}' ({entry.Index}) does not have an in-repository owner assignment."); + } + + HashSet localIds = localEntries.Select(entry => entry.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (string extraId in s_expectedOwnersById.Keys.Where(id => !localIds.Contains(id))) + { + errors.Add($"Ownership map contains '{extraId}', which is not a local .NET v1 registry id."); + } + } + + private static List ReadDotNetV1Registry(string repositoryRoot) + { + string registryPath = Path.Combine(repositoryRoot, "docs", "specs", "feature-usage-bit-registry.md"); + var entries = new List(); + bool inDotNetTable = false; + + foreach (string line in File.ReadLines(registryPath)) + { + if (!inDotNetTable) + { + inDotNetTable = StringComparer.Ordinal.Equals(line, DotNetTableHeading); + continue; + } + + if (line.StartsWith("## ", StringComparison.Ordinal)) + { + break; + } + + string[] columns = line.Split('|'); + if (columns.Length < 4 || + !int.TryParse(columns[1].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int index)) + { + continue; + } + + string id = columns[2].Trim().Trim('`'); + entries.Add(new RegistryEntry(index, id)); + } + + if (!inDotNetTable || entries.Count == 0) + { + throw new InvalidDataException($"Could not parse the .NET v1 table from '{registryPath}'."); + } + + return entries; + } + + private static List ReadFeatureDeclarations( + string repositoryRoot, + List errors) + { + string sourceRoot = Path.Combine(repositoryRoot, "dotnet", "src"); + var declarations = new List(); + + foreach (string filePath in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !IsBuildOutput(path))) + { + string source = File.ReadAllText(filePath); + if (!source.Contains("FeatureIndex", StringComparison.Ordinal)) + { + continue; + } + + CompilationUnitSyntax root = CSharpSyntaxTree.ParseText(source).GetCompilationUnitRoot(); + EnumDeclarationSyntax[] enums = root.DescendantNodes() + .OfType() + .Where(declaration => StringComparer.Ordinal.Equals(declaration.Identifier.ValueText, "FeatureIndex")) + .ToArray(); + + foreach (EnumDeclarationSyntax featureIndex in enums) + { + if (!featureIndex.Modifiers.Any(SyntaxKind.InternalKeyword)) + { + errors.Add($"{filePath}: FeatureIndex must be internal."); + } + + string owner = GetProjectOwner(sourceRoot, filePath); + foreach (EnumMemberDeclarationSyntax member in featureIndex.Members) + { + if (member.EqualsValue?.Value is not LiteralExpressionSyntax literal || + literal.Token.Value is not int index) + { + errors.Add($"{filePath}: '{member.Identifier.ValueText}' must have an explicit integer value."); + continue; + } + + declarations.Add(new FeatureDeclaration(owner, member.Identifier.ValueText, index, filePath)); + } + } + } + + return declarations; + } + + private static string[] FindMissingActivationReferences( + string repositoryRoot, + List declarations) + { + string sourceRoot = Path.Combine(repositoryRoot, "dotnet", "src"); + var references = new HashSet<(string Owner, string MemberName)>(); + + foreach (string filePath in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !IsBuildOutput(path))) + { + CompilationUnitSyntax root = CSharpSyntaxTree.ParseText(File.ReadAllText(filePath)).GetCompilationUnitRoot(); + string owner = GetProjectOwner(sourceRoot, filePath); + + foreach (MemberAccessExpressionSyntax memberAccess in root.DescendantNodes().OfType()) + { + if (IsFeatureIndexExpression(memberAccess.Expression)) + { + references.Add((owner, memberAccess.Name.Identifier.ValueText)); + } + } + } + + return declarations + .Where(declaration => !references.Contains((declaration.Owner, declaration.MemberName))) + .Select(declaration => + $"{declaration.Owner}.{declaration.MemberName} ({declaration.Index}) has no Stage 2 activation reference.") + .ToArray(); + } + + private static bool IsFeatureIndexExpression(ExpressionSyntax expression) + => expression is IdentifierNameSyntax identifierName + ? StringComparer.Ordinal.Equals(identifierName.Identifier.ValueText, "FeatureIndex") + : expression is MemberAccessExpressionSyntax memberAccess && + StringComparer.Ordinal.Equals(memberAccess.Name.Identifier.ValueText, "FeatureIndex"); + + private static string GetProjectOwner(string sourceRoot, string filePath) + { + string relativePath = Path.GetRelativePath(sourceRoot, filePath); + int separatorIndex = relativePath.IndexOf(Path.DirectorySeparatorChar); + return separatorIndex < 0 ? string.Empty : relativePath[..separatorIndex]; + } + + private static bool IsBuildOutput(string path) + => path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) || + path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase); + + private static string NormalizeRegistryKey(string value) + => string.Concat(value.Where(character => character is not '.' and not '_')); + + private static string FindRepositoryRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "CODE_OF_CONDUCT.md")) && + Directory.Exists(Path.Combine(directory.FullName, "dotnet", "src"))) + { + return directory.FullName; + } + } + + throw new DirectoryNotFoundException($"Could not find the repository root from '{AppContext.BaseDirectory}'."); + } + + private static void AssertNoErrors(IReadOnlyCollection errors) + { + if (errors.Count > 0) + { + Assert.Fail(Environment.NewLine + string.Join(Environment.NewLine, errors)); + } + } + + private sealed record RegistryEntry(int Index, string Id); + + private sealed record FeatureDeclaration(string Owner, string MemberName, int Index, string FilePath); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj new file mode 100644 index 00000000000..28c3b83b72b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/README.md b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/README.md new file mode 100644 index 00000000000..6b9df212b47 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/README.md @@ -0,0 +1,9 @@ +# .NET feature registry validation + +This project parses the .NET v1 table in +`docs/specs/feature-usage-bit-registry.md` and validates package-local +`FeatureIndex` declarations. + +The tests validate allocation, ownership, naming, range, uniqueness, external +exceptions, complete in-repository coverage, and production activation +references for every local feature index. diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs index 224bf87488b..0efcaa5e657 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs @@ -22,6 +22,37 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; public class ServiceCollectionExtensionsTests { + [Fact] + public void AddFoundryResponses_MarksFeatureUsed() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddFoundryResponses(); + + // Assert + AssertFeatureUsed(53); + } + + private static void AssertFeatureUsed(int featureIndex) + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + + string hexMask = userAgent[Prefix.Length..^1]; + int digitOffset = featureIndex / 4; + Assert.True(hexMask.Length > digitOffset); + char digit = char.ToLowerInvariant(hexMask[hexMask.Length - digitOffset - 1]); + int nibble = digit <= '9' ? digit - '0' : digit - 'a' + 10; + Assert.NotEqual(0, nibble & (1 << (featureIndex & 3))); + } + [Fact] public void AddFoundryResponses_RegistersResponseHandler() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs index 94999cfb643..7f597300df7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Internal; namespace Microsoft.Agents.AI.Foundry.UnitTests; @@ -30,7 +31,7 @@ public async Task AgentFrameworkUserAgentPolicy_AddsAgentFrameworkSegment_ToOutg #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], + perCallPolicies: [FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy], perTryPolicies: default, beforeTransportPolicies: default); @@ -58,7 +59,7 @@ public async Task AgentFrameworkUserAgentPolicy_DoesNotStampMeaiSegmentAsync() #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], + perCallPolicies: [FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy], perTryPolicies: default, beforeTransportPolicies: default); @@ -88,7 +89,7 @@ public async Task AgentFrameworkUserAgentPolicy_PreservesExistingUserAgent_WhenA #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), AgentFrameworkUserAgentPolicy.Instance], + perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy], perTryPolicies: default, beforeTransportPolicies: default); @@ -116,7 +117,11 @@ public async Task AgentFrameworkUserAgentPolicy_IsIdempotent_DoesNotDoubleStampA #pragma warning restore CA5399 var pipeline = ClientPipeline.Create( new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance, AgentFrameworkUserAgentPolicy.Instance], + perCallPolicies: + [ + FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy, + FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy, + ], perTryPolicies: default, beforeTransportPolicies: default); @@ -141,8 +146,8 @@ public void AgentFrameworkUserAgentPolicy_ExposesSingletonInstance() // Two reads of the static property must return the same instance. The policy is stateless // and shared; allocating a fresh instance per registration site would bloat memory and // defeat the dedup logic in OpenAIRequestPoliciesReflection.AddPolicyIfMissing. - var first = AgentFrameworkUserAgentPolicy.Instance; - var second = AgentFrameworkUserAgentPolicy.Instance; + AgentFrameworkUserAgentPolicy first = FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy; + AgentFrameworkUserAgentPolicy second = FoundryUserAgentPolicies.Registration.BaseUserAgentPolicy; Assert.Same(first, second); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FeatureUsageUserAgentPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FeatureUsageUserAgentPolicyTests.cs new file mode 100644 index 00000000000..24f096d41d5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FeatureUsageUserAgentPolicyTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Internal; +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +public sealed class FeatureUsageUserAgentPolicyTests +{ + [Theory] + [InlineData("https://services.ai.azure.com/")] + [InlineData("https://project.services.ai.azure.com/api/projects/test")] + [InlineData("https://SERVICES.AI.AZURE.COM./")] + [InlineData("https://inference.ai.azure.com/")] + [InlineData("https://model.inference.ai.azure.com/models")] + public void IsApprovedOrigin_AcceptsExactAndDotSubdomainHttpsFoundryOrigins(string value) + { + // Arrange / Act + bool approved = FoundryUserAgentPolicies.Registration.IsApprovedOrigin(new Uri(value)); + + // Assert + Assert.True(approved); + } + + [Theory] + [InlineData("http://project.services.ai.azure.com/")] + [InlineData("https://services.ai.azure.com.example.com/")] + [InlineData("https://projectservices.ai.azure.com/")] + [InlineData("https://evilservices.ai.azure.com/")] + [InlineData("https://inference.ai.azure.com.evil.test/")] + [InlineData("https://openai.azure.com/")] + [InlineData("https://example.com/")] + public void IsApprovedOrigin_RejectsHttpCustomLookalikeAndUnrelatedOrigins(string value) + { + // Arrange / Act + bool approved = FoundryUserAgentPolicies.Registration.IsApprovedOrigin(new Uri(value)); + + // Assert + Assert.False(approved); + } + + [Fact] + public async Task Policy_RefreshesLiveTokenOnEveryEligibleRequestAsync() + { + // Arrange + string? token = "v1.1"; + var policy = new FeatureUsageUserAgentPolicy( + FoundryUserAgentPolicies.Registration.IsApprovedOrigin, + (userAgent, includeFeatureToken) => + includeFeatureToken ? $"{userAgent.Split(' ')[0]} (feat={token})" : userAgent.Split(' ')[0]); + var capturedUserAgents = new List(); + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + ClientPipeline pipeline = CreatePipeline(httpClient, policy, "app/1.0 (feat=v1.ff)", capturedUserAgents); + + // Act + await SendAsync(pipeline, new Uri("https://project.services.ai.azure.com/first")); + token = "v1.5"; + await SendAsync(pipeline, new Uri("https://project.services.ai.azure.com/second")); + + // Assert + Assert.Equal( + ["app/1.0 (feat=v1.1)", "app/1.0 (feat=v1.5)"], + capturedUserAgents); + } + + [Fact] + public async Task Policy_EmptyToken_PreservesBaseHeaderByteForByteAsync() + { + // Arrange + const string BaseHeader = "vendor/2.0 app/1.0 (custom=value)"; + var policy = new FeatureUsageUserAgentPolicy( + FoundryUserAgentPolicies.Registration.IsApprovedOrigin, + static (userAgent, _) => userAgent); + var capturedUserAgents = new List(); + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + ClientPipeline pipeline = CreatePipeline(httpClient, policy, BaseHeader, capturedUserAgents); + + // Act + await SendAsync(pipeline, new Uri("https://project.services.ai.azure.com/")); + + // Assert + Assert.Equal(BaseHeader, Assert.Single(capturedUserAgents)); + } + + [Fact] + public async Task Policy_IneligibleOrigin_RemovesStaleCommentWithoutChangingBaseHeaderAsync() + { + // Arrange + var policy = new FeatureUsageUserAgentPolicy( + FoundryUserAgentPolicies.Registration.IsApprovedOrigin, + static (userAgent, includeFeatureToken) => + includeFeatureToken ? userAgent : userAgent.Replace(" (feat=v1.1)", string.Empty)); + var capturedUserAgents = new List(); + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + ClientPipeline pipeline = CreatePipeline( + httpClient, + policy, + "vendor/2.0 app/1.0 (feat=v1.1)", + capturedUserAgents); + + // Act + await SendAsync(pipeline, new Uri("https://project.services.ai.azure.com.evil.test/")); + + // Assert + Assert.Equal("vendor/2.0 app/1.0", Assert.Single(capturedUserAgents)); + } + + [Fact] + public void Registration_UsesConditionalWeakTableToRegisterAtMostOnce() + { + // Arrange + var policies = new OpenAIRequestPolicies(); + + // Act + bool first = FoundryUserAgentPolicies.Registration.TryRegister(policies); + bool second = FoundryUserAgentPolicies.Registration.TryRegister(policies); + + // Assert + Assert.True(first); + Assert.False(second); + Assert.Equal(2, EntriesCount(policies)); + } + + [Fact] + public void Registration_IsAtMostOnceUnderConcurrency() + { + // Arrange + var policies = new OpenAIRequestPolicies(); + var results = new ConcurrentBag(); + + // Act + Parallel.For(0, 32, _ => results.Add(FoundryUserAgentPolicies.Registration.TryRegister(policies))); + + // Assert + Assert.Equal(1, results.Count(static added => added)); + Assert.Equal(2, EntriesCount(policies)); + } + + private static ClientPipeline CreatePipeline( + HttpClient httpClient, + FeatureUsageUserAgentPolicy policy, + string userAgent, + List capturedUserAgents) + { + return ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [new SeedUserAgentPolicy(userAgent)], + perTryPolicies: default, + beforeTransportPolicies: [policy, new CaptureUserAgentPolicy(capturedUserAgents)]); + } + + private static async Task SendAsync(ClientPipeline pipeline, Uri uri) + { + PipelineMessage message = pipeline.CreateMessage(); + message.Request.Method = "GET"; + message.Request.Uri = uri; + await pipeline.SendAsync(message); + } + + private static int EntriesCount(OpenAIRequestPolicies policies) + { + FieldInfo? field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return ((Array)field.GetValue(policies)!).Length; + } + + private sealed class SeedUserAgentPolicy(string value) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + private sealed class CaptureUserAgentPolicy(List capturedUserAgents) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + _ = message.Request.Headers.TryGetValue("User-Agent", out string? userAgent); + capturedUserAgents.Add(userAgent); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + _ = message.Request.Headers.TryGetValue("User-Agent", out string? userAgent); + capturedUserAgents.Add(userAgent); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + private sealed class RecordingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } + } +} + +#pragma warning restore MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 454ab36b168..72e9fa342c0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -97,6 +97,54 @@ public void Constructor_WithValidParams_CreatesAgent() Assert.Equal("A test agent", agent.Description); } + [Fact] + public void ProjectEndpointConstructor_DefaultTransport_RegistersFeatureUsagePolicy() + { + // Arrange / Act + var agent = new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test instructions"); + + // Assert + FoundryChatClient chatClient = Assert.IsType( + agent.GetService()); + OpenAIRequestPolicies policies = Assert.IsType( + chatClient.GetService()); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); + } + + [Fact] + public void ProjectEndpointConstructor_CustomTransport_RegistersFeatureUsagePolicy() + { + // Arrange + using var httpClient = new HttpClient(); + var options = new AIProjectClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + + // Act + var agent = new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test instructions", + clientOptions: options); + + // Assert + FoundryChatClient chatClient = Assert.IsType( + agent.GetService()); + OpenAIRequestPolicies policies = Assert.IsType( + chatClient.GetService()); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); + } + #endregion #region Property tests diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs index 3bace55df2d..3e9bde98d65 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs @@ -434,7 +434,7 @@ public void Mode3_AgentEndpoint_MaterializedAIProjectClient_TargetsParsedProject // the well-known private field. If the SDK field shape changes this guard fails loudly. var field = typeof(AIProjectClient).GetField("_endpoint", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field); - var actualEndpoint = (Uri)field!.GetValue(aiProjectClient!)!; + var actualEndpoint = (Uri)field!.GetValue(aiProjectClient)!; Assert.Equal("https://example.com/api/projects/myproj", actualEndpoint.AbsoluteUri.TrimEnd('/')); } @@ -561,14 +561,17 @@ public void ParseAgentEndpoint_ThrowsOnNullUri() public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies() { // Arrange + Act: constructing a FoundryChatClient should register the - // AgentFrameworkUserAgentPolicy and ServedModelPolicy on the inner chat client's OpenAIRequestPolicies. + // base User-Agent, feature-usage, and served-model policies on the inner chat client's OpenAIRequestPolicies. var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini"); // Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes - // OpenAIRequestPolicies via GetService, and both policies are present in its entries. + // OpenAIRequestPolicies via GetService, and all three policies are present in its entries. var policies = chatClient.GetService(); Assert.NotNull(policies); - Assert.Equal(2, EntriesCount(policies!)); + Assert.Equal(3, EntriesCount(policies!)); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies!, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); } [Fact] @@ -576,7 +579,7 @@ public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClient { // Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via // :this(...) into the AgentReference ctor. If the policy registration code were - // inadvertently called twice along the chain, we would see more than 2 entries. + // inadvertently called twice along the chain, we would see more than 3 entries. var projectClient = CreateProjectClient(); var agentVersion = ModelReaderWriter.Read( BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; @@ -588,11 +591,75 @@ public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClient // via :this(...), each policy is registered exactly once on the inner pipeline. var policies = chatClient.GetService(); Assert.NotNull(policies); - Assert.Equal(2, EntriesCount(policies!)); + Assert.Equal(3, EntriesCount(policies!)); Assert.Same(agentVersion, chatClient.GetService()); Assert.NotNull(chatClient.GetService()); } + [Fact] + public void Register_FeatureUsagePolicy_ForAgentEndpointDefaultTransport() + { + // Arrange / Act + var chatClient = new FoundryChatClient( + new Uri("https://project.services.ai.azure.com/api/projects/test/agents/agent/endpoint/protocols/openai"), + new FakeAuthenticationTokenProvider(), + clientOptions: null); + + // Assert + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(3, EntriesCount(policies!)); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies!, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); + } + + [Fact] + public void Register_FeatureUsagePolicy_ForCustomTransport() + { + // Arrange + using var httpClient = new HttpClient(); + var options = new ProjectOpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + + // Act + var chatClient = new FoundryChatClient( + new Uri("https://project.services.ai.azure.com/api/projects/test/agents/agent/endpoint/protocols/openai"), + new FakeAuthenticationTokenProvider(), + options); + + // Assert + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(3, EntriesCount(policies!)); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies!, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); + } + + [Fact] + public void Register_FeatureUsagePolicy_ForCallerOwnedProjectClient() + { + // Arrange + AIProjectClient projectClient = CreateProjectClient(); + + // Act + var chatClient = new FoundryChatClient( + projectClient, + new Uri("https://project.services.ai.azure.com/api/projects/test/agents/agent/endpoint/protocols/openai"), + clientOptions: null); + + // Assert + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(3, EntriesCount(policies!)); + Assert.True(OpenAIRequestPoliciesReflection.ContainsPolicy( + policies!, + FoundryUserAgentPolicies.Registration.FeatureUsagePolicy)); + } + #endregion #region Helpers diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryFeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryFeatureUsageActivationTests.cs new file mode 100644 index 00000000000..85534e3d3e7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryFeatureUsageActivationTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Microsoft.Agents.AI.Foundry.UnitTests.Memory; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +[CollectionDefinition("FoundryFeatureUsageActivation", DisableParallelization = true)] +public sealed class FoundryFeatureUsageActivationGroup; + +[Collection("FoundryFeatureUsageActivation")] +public sealed class FoundryFeatureUsageActivationTests : IDisposable +{ + public FoundryFeatureUsageActivationTests() => ResetFeatureUsage(); + + public void Dispose() => ResetFeatureUsage(); + + [Fact] + public void ConstructorsAndFactories_DoNotMarkFeatures() + { + // Arrange + using TestableAIProjectClient testClient = new(); + Uri agentEndpoint = new("https://test.services.ai.azure.com/api/projects/test/agents/test-agent/endpoint/protocols/openai"); + + // Act + _ = new FoundryChatClient(testClient.Client, "gpt-4o-mini"); + _ = new FoundryAgent(agentEndpoint, new FakeAuthenticationTokenProvider()); + _ = new FoundryMemoryProvider( + testClient.Client, + "memory-store", + _ => new(new FoundryMemoryProviderScope("scope"))); +#if NET8_0_OR_GREATER + _ = new FoundryEvals(testClient.Client, "gpt-4o-mini"); +#endif + _ = FoundryAITool.CreateHostedMcpToolbox("toolbox"); + + // Assert + Assert.Null(GetFeatureToken()); + } + + [Fact] + public async Task FoundryChatClient_StreamingMarksAtEnumerationAsync() + { + // Arrange + using HttpHandlerAssert handler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"), + }); + using HttpClient httpClient = CreateHttpClient(handler); + AIProjectClient projectClient = CreateProjectClient(httpClient); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + // Act + IAsyncEnumerable updates = + chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hello")]); + + // Assert + Assert.Null(GetFeatureToken()); + + await using IAsyncEnumerator enumerator = updates.GetAsyncEnumerator(); + try + { + _ = await enumerator.MoveNextAsync(); + } + catch + { + // The minimal SSE body only needs to drive the request path. + } + + AssertFeatureUsed(FeatureIndex.FoundryChatClient); + } + + [Fact] + public async Task FoundryAgent_ExecutionMarksAgentAndChatClientOnCurrentRequestAsync() + { + // Arrange + string? userAgent = null; + using HttpHandlerAssert handler = new(request => + { + userAgent = GetHeader(request, "User-Agent"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); + using HttpClient httpClient = CreateHttpClient(handler); + var options = new ProjectOpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + var agent = new FoundryAgent( + new Uri("https://test.services.ai.azure.com/api/projects/test/agents/test-agent/endpoint/protocols/openai"), + new FakeAuthenticationTokenProvider(), + options); + + // Act + await agent.RunAsync("Hello"); + + // Assert + AssertFeatureUsed(FeatureIndex.FoundryChatClient); + AssertFeatureUsed(FeatureIndex.FoundryAgent); + Assert.Contains($"(feat={GetFeatureToken()})", userAgent, StringComparison.Ordinal); + } + + [Fact] + public async Task FoundryMemory_FirstProviderHookMarksFeatureAsync() + { + // Arrange + using TestableAIProjectClient testClient = new(); + var provider = new FoundryMemoryProvider( + testClient.Client, + "memory-store", + _ => new(new FoundryMemoryProviderScope("scope"))); + var context = new AIContextProvider.InvokingContext( + new Mock().Object, + new Mock().Object, + new AIContext()); + + // Act + _ = await provider.InvokingAsync(context); + + // Assert + AssertFeatureUsed(FeatureIndex.FoundryMemory); + } + +#if NET8_0_OR_GREATER + [Fact] + public async Task FoundryEvals_FirstEvaluationMarksFeatureAsync() + { + // Arrange + using HttpHandlerAssert handler = new(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }); + using HttpClient httpClient = CreateHttpClient(handler); + AIProjectClient projectClient = CreateProjectClient(httpClient); + var evals = new FoundryEvals(projectClient, "gpt-4o-mini"); + + // Act + await Assert.ThrowsAnyAsync( + () => evals.EvaluateAsync([new EvalItem("question", "answer")])); + + // Assert + AssertFeatureUsed(FeatureIndex.FoundryEvals); + } +#endif + + [Fact] + public async Task FoundryToolbox_MarksOnlyWhenMarkerParticipatesInOutgoingRequestAsync() + { + // Arrange + string? requestBody = null; + string? userAgent = null; + using HttpHandlerAssert handler = new(async request => + { + requestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync().ConfigureAwait(false); + userAgent = GetHeader(request, "User-Agent"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); + using HttpClient httpClient = CreateHttpClient(handler); + AIProjectClient projectClient = CreateProjectClient(httpClient); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var options = new ChatOptions + { + Tools = [FoundryAITool.CreateHostedMcpToolbox("calendar-tools", "v2")], + }; + + Assert.Null(GetFeatureToken()); + + // Act + _ = await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options); + + // Assert + Assert.Contains("\"type\":\"mcp\"", requestBody, StringComparison.Ordinal); + Assert.Contains("calendar-tools", requestBody, StringComparison.Ordinal); + Assert.Contains("v2", requestBody, StringComparison.Ordinal); + AssertFeatureUsed(FeatureIndex.FoundryChatClient); + AssertFeatureUsed(FeatureIndex.FoundryToolbox); + Assert.Contains($"(feat={GetFeatureToken()})", userAgent, StringComparison.Ordinal); + } + + private static AIProjectClient CreateProjectClient(HttpClient httpClient) + => new( + new Uri("https://test.services.ai.azure.com/api/projects/test"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + private static HttpClient CreateHttpClient(HttpMessageHandler handler) + { +#pragma warning disable CA5399 + return new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + } + + private static string? GetHeader(HttpRequestMessage request, string name) + => request.Headers.TryGetValues(name, out IEnumerable? values) + ? string.Join(" ", values) + : null; + + private static string? GetFeatureToken() + => (string?)GetFeatureUsageMethod("GetToken").Invoke(null, null); + + private static void AssertFeatureUsed(FeatureIndex feature) + { + string token = Assert.IsType(GetFeatureToken()); + string hexMask = token.Substring(token.IndexOf('.') + 1); + ulong mask = ulong.Parse(hexMask, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + Assert.NotEqual(0UL, mask & (1UL << (int)feature)); + } + + private static void ResetFeatureUsage() + => GetFeatureUsageMethod("ResetStateForTests").Invoke(null, null); + + private static MethodInfo GetFeatureUsageMethod(string name) + => typeof(FeatureUsage).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"FeatureUsage.{name} was not found."); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 05b94b7b82f..5418c8b49e0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -8,6 +8,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/PolicyPipelineInvestigationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/PolicyPipelineInvestigationTests.cs new file mode 100644 index 00000000000..f1fcc12a924 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/PolicyPipelineInvestigationTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using OpenAI.Chat; +using OpenAI.Responses; + +#pragma warning disable MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Executable probes documenting the System.ClientModel policy behavior Stage 1 can rely on. +/// +public sealed class PolicyPipelineInvestigationTests +{ + [Fact] + public async Task PipelinePositions_PerCallRunsOnce_PerTryAndBeforeTransportRunForEveryRetry_InOrderAsync() + { + // Arrange + var events = new List(); + using var handler = new RetryOnceHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + var options = new ClientPipelineOptions + { + RetryPolicy = new ClientRetryPolicy(maxRetries: 1), + Transport = new HttpClientPipelineTransport(httpClient), + }; + ClientPipeline pipeline = ClientPipeline.Create( + options, + perCallPolicies: [new RecordingPolicy("call", events)], + perTryPolicies: [new RecordingPolicy("try", events)], + beforeTransportPolicies: [new RecordingPolicy("transport", events)]); + + // Act + PipelineMessage message = pipeline.CreateMessage(); + message.Request.Method = "GET"; + message.Request.Uri = new Uri("https://example.test/retry"); + await pipeline.SendAsync(message); + + // Assert + Assert.Equal(2, handler.Count); + Assert.Equal(["call", "try", "transport", "try", "transport"], events); + } + + private sealed class RecordingPolicy(string name, List events) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + events.Add(name); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + events.Add(name); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + private sealed class RetryOnceHandler : HttpMessageHandler + { + public int Count { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Count++; + return Task.FromResult(new HttpResponseMessage( + this.Count == 1 ? HttpStatusCode.InternalServerError : HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } + } +} + +/// +/// Executable probes for caller-owned Azure OpenAI clients wrapped by Microsoft.Extensions.AI. +/// +public sealed class AzureOpenAIRequestPoliciesInvestigationTests +{ + [Fact] + public async Task CallerOwnedChatAndResponsesWrappers_HaveIsolatedPolicies_PreserveTransport_AndRunAfterBaseUserAgentAsync() + { + // Arrange + using var handler = new AzureOpenAIRecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + var options = new AzureOpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + var azureClient = new AzureOpenAIClient( + new Uri("https://resource.openai.azure.com/"), + new ApiKeyCredential("test-key"), + options); + + ChatClient callerOwnedChatClient = azureClient.GetChatClient("deployment"); + ResponsesClient callerOwnedResponsesClient = azureClient.GetResponsesClient(); + IChatClient chatWrapper = callerOwnedChatClient.AsIChatClient(); + IChatClient secondChatWrapper = callerOwnedChatClient.AsIChatClient(); + IChatClient responsesWrapper = callerOwnedResponsesClient.AsIChatClient("deployment"); + OpenAIRequestPolicies chatPolicies = Assert.IsType(chatWrapper.GetService()); + OpenAIRequestPolicies secondChatPolicies = Assert.IsType( + secondChatWrapper.GetService()); + OpenAIRequestPolicies responsesPolicies = Assert.IsType(responsesWrapper.GetService()); + var probe = new UserAgentOrderingProbePolicy(); + chatPolicies.AddPolicy(probe, PipelinePosition.BeforeTransport); + + // Act + await IgnoreResponseParsingFailureAsync(() => chatWrapper.GetResponseAsync("hi")); + await IgnoreResponseParsingFailureAsync(() => secondChatWrapper.GetResponseAsync("hi")); + await IgnoreResponseParsingFailureAsync(() => responsesWrapper.GetResponseAsync("hi")); + + // Assert + Assert.NotSame(chatPolicies, secondChatPolicies); + Assert.NotSame(chatPolicies, responsesPolicies); + Assert.Equal(3, handler.Requests.Count); + Assert.All(handler.Requests, static request => Assert.Equal("resource.openai.azure.com", request.Uri.Host)); + Assert.Contains(handler.Requests, static request => request.Marker == "chat-only"); + Assert.Equal(2, handler.Requests.Count(static request => request.Marker is null)); + Assert.Single(probe.ObservedUserAgents); + Assert.Contains("azsdk-net-AI.OpenAI/", probe.ObservedUserAgents[0]); + Assert.Contains("MEAI/", probe.ObservedUserAgents[0]); + } + + [Fact] + public async Task CallerOwnedAzureClient_PreservesActualAzureOpenAIAndLookalikeOriginsAsync() + { + // Arrange + using var handler = new AzureOpenAIRecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + + IChatClient approved = CreateChatWrapper( + new Uri("https://resource.openai.azure.com/"), + httpClient); + IChatClient lookalike = CreateChatWrapper( + new Uri("https://resource.openai.azure.com.evil.test/"), + httpClient); + + // Act + await IgnoreResponseParsingFailureAsync(() => approved.GetResponseAsync("hi")); + await IgnoreResponseParsingFailureAsync(() => lookalike.GetResponseAsync("hi")); + + // Assert + Assert.Equal(2, handler.Requests.Count); + Assert.Equal("resource.openai.azure.com", handler.Requests[0].Uri.Host); + Assert.Equal("resource.openai.azure.com.evil.test", handler.Requests[1].Uri.Host); + Assert.True(IsCandidateAzureOpenAIOrigin(handler.Requests[0].Uri)); + Assert.False(IsCandidateAzureOpenAIOrigin(handler.Requests[1].Uri)); + } + + private static IChatClient CreateChatWrapper(Uri endpoint, HttpClient httpClient) + { + var options = new AzureOpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + return new AzureOpenAIClient(endpoint, new ApiKeyCredential("test-key"), options) + .GetChatClient("deployment") + .AsIChatClient(); + } + + private static bool IsCandidateAzureOpenAIOrigin(Uri uri) + { + string host = uri.IdnHost.TrimEnd('.'); + return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + (string.Equals(host, "openai.azure.com", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".openai.azure.com", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase)); + } + + private static async Task IgnoreResponseParsingFailureAsync(Func> operation) + { + try + { + await operation(); + } + catch (Exception exception) + { + // The fake response body is intentionally not a valid service payload. + _ = exception; + } + } + + private sealed class UserAgentOrderingProbePolicy : PipelinePolicy + { + public List ObservedUserAgents { get; } = []; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.ObserveAndStamp(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.ObserveAndStamp(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private void ObserveAndStamp(PipelineMessage message) + { + _ = message.Request.Headers.TryGetValue("User-Agent", out string? userAgent); + this.ObservedUserAgents.Add(userAgent ?? string.Empty); + message.Request.Headers.Set("x-policy-probe", "chat-only"); + } + } + + private sealed class AzureOpenAIRecordingHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Requests.Add(new RecordedRequest( + request.RequestUri!, + request.Headers.TryGetValues("x-policy-probe", out IEnumerable? values) + ? values.Single() + : null)); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } + } + + private sealed class RecordedRequest(Uri uri, string? marker) + { + public Uri Uri { get; } = uri; + + public string? Marker { get; } = marker; + } +} + +#pragma warning restore MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..5f570692a29 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Copilot; + +namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() => FeatureUsageAssert.Reset(); + + [Fact] + public async Task RunAsync_WhenCancelled_ActivatesGitHubCopilotAsync() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + // Act + _ = await Assert.ThrowsAnyAsync( + () => agent.RunAsync("hello", cancellationToken: cancellationSource.Token)); + + // Assert + FeatureUsageAssert.Marked(57); + } + + [Fact] + public async Task RunStreamingAsync_IsColdAndActivatesGitHubCopilotAsync() + { + // Arrange + CopilotClient copilotClient = new(new CopilotClientOptions()); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + // Act + IAsyncEnumerable stream = + agent.RunStreamingAsync("hello", cancellationToken: cancellationSource.Token); + + // Assert + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(); + _ = await Assert.ThrowsAnyAsync( + async () => await enumerator.MoveNextAsync()); + FeatureUsageAssert.Marked(57); + } + + public void Dispose() => FeatureUsageAssert.Reset(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 60398000ca6..25316d47057 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -141,15 +141,15 @@ public void CopySessionConfig_CopiesAllProperties() Assert.Equal("gpt-4o", result.Model); Assert.Equal("high", result.ReasoningEffort); Assert.Equal(systemMessage, result.SystemMessage); - Assert.Equal(new List { "tool1", "tool2" }, result.AvailableTools); - Assert.Equal(new List { "tool3" }, result.ExcludedTools); + Assert.Equal(["tool1", "tool2"], result.AvailableTools); + Assert.Equal(["tool3"], result.ExcludedTools); Assert.Equal("/workspace", result.WorkingDirectory); Assert.Equal("/config", result.ConfigDirectory); Assert.Same(hooks, result.Hooks); Assert.Same(infiniteSessions, result.InfiniteSessions); Assert.Same(permissionHandler, result.OnPermissionRequest); Assert.Same(userInputHandler, result.OnUserInputRequest); - Assert.Equal(new List { "skill1" }, result.DisabledSkills); + Assert.Equal(["skill1"], result.DisabledSkills); Assert.True(result.Streaming); } @@ -191,8 +191,8 @@ public void CopyResumeSessionConfig_CopiesAllProperties() Assert.Equal("high", result.ReasoningEffort); Assert.Same(tools, result.Tools); Assert.Same(systemMessage, result.SystemMessage); - Assert.Equal(new List { "tool1", "tool2" }, result.AvailableTools); - Assert.Equal(new List { "tool3" }, result.ExcludedTools); + Assert.Equal(["tool1", "tool2"], result.AvailableTools); + Assert.Equal(["tool3"], result.ExcludedTools); Assert.Equal("/workspace", result.WorkingDirectory); Assert.Equal("/config", result.ConfigDirectory); Assert.Same(hooks, result.Hooks); @@ -200,7 +200,7 @@ public void CopyResumeSessionConfig_CopiesAllProperties() Assert.Same(permissionHandler, result.OnPermissionRequest); Assert.Same(userInputHandler, result.OnUserInputRequest); Assert.Same(mcpServers, result.McpServers); - Assert.Equal(new List { "skill1" }, result.DisabledSkills); + Assert.Equal(["skill1"], result.DisabledSkills); Assert.True(result.Streaming); } diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj index 185130f9f39..475747cd2b9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj @@ -9,5 +9,11 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..e17373d6056 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +[Collection(nameof(HarnessFeatureUsageActivationTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() + { + ResetFeatureUsage(); + } + + [Fact] + public void Construction_DoesNotActivateHarnessOrCoreAgent() + { + // Arrange + var chatClient = new Mock().Object; + + // Act + _ = new HarnessAgent(chatClient, CreateOptions()); + + // Assert + Assert.Null(GetFeatureToken()); + } + + [Fact] + public async Task NonStreamingExecution_ActivatesHarnessAndDelegatedChatClientAgentAsync() + { + // Arrange + Mock chatClient = new(); + chatClient + .Setup(client => client.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "done")])); + var agent = new HarnessAgent(chatClient.Object, CreateOptions()); + + // Act + _ = await agent.RunAsync("hello"); + + // Assert + Assert.Equal("v1.3", GetFeatureToken()); + } + + [Fact] + public async Task StreamingExecution_IsColdAndActivatesHarnessAndDelegatedChatClientAgentAsync() + { + // Arrange + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(EmptyChatResponseUpdatesAsync()); + var agent = new HarnessAgent(chatClient.Object, CreateOptions()); + + // Act + IAsyncEnumerable stream = agent.RunStreamingAsync("hello"); + + // Assert + Assert.Null(GetFeatureToken()); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(); + Assert.False(await enumerator.MoveNextAsync()); + Assert.Equal("v1.3", GetFeatureToken()); + } + + public void Dispose() + { + ResetFeatureUsage(); + } + + private static string? GetFeatureToken() + => (string?)typeof(FeatureUsage) + .GetMethod("GetToken", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); + + private static void ResetFeatureUsage() + => typeof(FeatureUsage) + .GetMethod("ResetStateForTests", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); + + private static HarnessAgentOptions CreateOptions() => new() + { + DisableApprovalNotRequiredFunctionBypassing = true, + DisableApprovalResponseBinding = true, + DisableAgentModeProvider = true, + DisableAgentSkillsProvider = true, + DisableCompaction = true, + DisableFileMemory = true, + DisableOpenTelemetry = true, + DisableTodoProvider = true, + DisableToolAutoApproval = true, + DisableWebSearch = true, + ChatHistoryProvider = new NoOpChatHistoryProvider(), + }; + + private static async IAsyncEnumerable EmptyChatResponseUpdatesAsync() + { + await Task.CompletedTask; + yield break; + } + + private sealed class NoOpChatHistoryProvider : ChatHistoryProvider + { + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + => new([]); + + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + => default; + } +} + +[CollectionDefinition(nameof(HarnessFeatureUsageActivationTestGroup), DisableParallelization = true)] +public sealed class HarnessFeatureUsageActivationTestGroup; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index e5fa337e865..df4669a014a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -14,6 +14,42 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; /// public sealed class A2AEndpointRouteBuilderExtensionsTests { + [Fact] + public void MapA2AJsonRpc_MarksFeatureUsed() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient chatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", chatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act + _ = app.MapA2AJsonRpc(agentBuilder, "/a2a"); + + // Assert + AssertFeatureUsed(73); + } + + private static void AssertFeatureUsed(int featureIndex) + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + + string hexMask = userAgent[Prefix.Length..^1]; + int digitOffset = featureIndex / 4; + Assert.True(hexMask.Length > digitOffset); + char digit = char.ToLowerInvariant(hexMask[hexMask.Length - digitOffset - 1]); + int nibble = digit <= '9' ? digit - '0' : digit - 'a' + 10; + Assert.NotEqual(0, nibble & (1 << (featureIndex & 3))); + } + /// /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index d22297c01e9..c8a43b41ae8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -18,6 +18,40 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; /// public sealed class AGUIEndpointRouteBuilderExtensionsTests { + [Fact] + public void MapAGUIServer_MarksFeatureUsed() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + _ = endpointsMock.Object.MapAGUIServer("/api/agent", new TestAgent()); + + // Assert + AssertFeatureUsed(63); + } + + private static void AssertFeatureUsed(int featureIndex) + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + + string hexMask = userAgent[Prefix.Length..^1]; + int digitOffset = featureIndex / 4; + Assert.True(hexMask.Length > digitOffset); + char digit = char.ToLowerInvariant(hexMask[hexMask.Length - digitOffset - 1]); + int nibble = digit <= '9' ? digit - '0' : digit - 'a' + 10; + Assert.NotEqual(0, nibble & (1 << (featureIndex & 3))); + } + [Fact] public void MapAGUIServer_MapsEndpoint_AtSpecifiedPattern() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs index e3effac07a5..4f820fe0903 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/EndpointRouteBuilderExtensionsTests.cs @@ -13,6 +13,42 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; /// public sealed class EndpointRouteBuilderExtensionsTests { + [Fact] + public void MapOpenAIResponses_MarksFeatureUsed() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient chatClient = new TestHelpers.SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", chatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddOpenAIResponses(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + // Act + _ = app.MapOpenAIResponses(agent); + + // Assert + AssertFeatureUsed(74); + } + + private static void AssertFeatureUsed(int featureIndex) + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + + string hexMask = userAgent[Prefix.Length..^1]; + int digitOffset = featureIndex / 4; + Assert.True(hexMask.Length > digitOffset); + char digit = char.ToLowerInvariant(hexMask[hexMask.Length - digitOffset - 1]); + int nibble = digit <= '9' ? digit - '0' : digit - 'a' + 10; + Assert.NotEqual(0, nibble & (1 << (featureIndex & 3))); + } + /// /// Verifies that MapOpenAIResponses throws ArgumentNullException for null endpoints. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs index 9229deb9cb4..ca542c46377 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -14,6 +15,71 @@ namespace Microsoft.Agents.AI.Hosting.UnitTests; public class AgentHostingServiceCollectionExtensionsTests { + [Fact] + public async Task AIHostAgent_RunAsync_MarksFeatureUsedAsync() + { + // Arrange + var hostAgent = new AIHostAgent(new TestEchoAgent(name: "hosted-agent"), new NoopAgentSessionStore()); + ResetFeatureUsage(); + + // Act + _ = await hostAgent.RunAsync("hello"); + + // Assert + AssertFeatureUsed(71); + } + + [Fact] + public async Task AIHostAgent_RunStreamingAsync_MarksOnlyOnEnumerationAsync() + { + // Arrange + var hostAgent = new AIHostAgent(new TestEchoAgent(name: "hosted-agent"), new NoopAgentSessionStore()); + ResetFeatureUsage(); + + // Act + IAsyncEnumerable updates = hostAgent.RunStreamingAsync("hello"); + + // Assert + AssertFeatureNotUsed(); + await foreach (AgentResponseUpdate _ in updates) + { + } + AssertFeatureUsed(71); + } + + private static void AssertFeatureUsed(int featureIndex) + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent(string.Empty); +#pragma warning restore MAAI001 + const string Prefix = "(feat=v1."; + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + + string hexMask = userAgent[Prefix.Length..^1]; + int digitOffset = featureIndex / 4; + Assert.True(hexMask.Length > digitOffset); + char digit = char.ToLowerInvariant(hexMask[hexMask.Length - digitOffset - 1]); + int nibble = digit <= '9' ? digit - '0' : digit - 'a' + 10; + Assert.NotEqual(0, nibble & (1 << (featureIndex & 3))); + } + + private static void AssertFeatureNotUsed() + { +#pragma warning disable MAAI001 + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); +#pragma warning restore MAAI001 + } + + private static void ResetFeatureUsage() + { + MethodInfo? reset = typeof(FeatureUsage).GetMethod( + "ResetStateForTests", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(reset); + reset.Invoke(obj: null, parameters: null); + } + /// /// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..84b5c3ddc12 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hyperlight.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests +{ + [Fact] + public async Task InvokingAsync_MarksFeatureUsageAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(); + var context = new AIContextProvider.InvokingContext( + new Mock().Object, + session: null, + new AIContext()); + FeatureUsageAssert.Reset(); + + // Act + _ = await provider.InvokingAsync(context); + + // Assert + FeatureUsageAssert.Marked(70); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj index 2a614e49ca0..0964d4e4ada 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -13,4 +13,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..cc222f43e85 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests +{ + [Fact] + public async Task InvokingAsync_MarksFeatureUsageAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "python", + new LocalCodeActProviderOptions { ValidationDisabled = true }); + var context = new AIContextProvider.InvokingContext( + new Mock().Object, + session: null, + new AIContext()); + FeatureUsageAssert.Reset(); + + // Act + _ = await provider.InvokingAsync(context); + + // Assert + FeatureUsageAssert.Marked(72); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj index da3a1727bc1..fdfd8cf8c52 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/Microsoft.Agents.AI.LocalCodeAct.UnitTests.csproj @@ -8,4 +8,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..a9a3e59aefa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Skills.Mcp.UnitTests; +using ModelContextProtocol.Server; + +namespace Microsoft.Agents.AI.Mcp.UnitTests; + +[Collection(nameof(McpFeatureUsageActivationTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() + { + ResetFeatureUsage(); + } + + [Fact] + public async Task ListAgentToolsWithTasksAsync_ActivatesCoreMcpAsync() + { + // Arrange + McpServerPrimitiveCollection tools = + [ + TestTools.Create("tool", () => "result"), + ]; + await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools); + ResetFeatureUsage(); + + // Act + _ = await fixture.Client.ListAgentToolsWithTasksAsync(); + + // Assert + Assert.Equal("v1.4000", GetFeatureToken()); + } + + [Fact] + public async Task McpSkillsSource_ActivatesOnlyWhenLoadedAsync() + { + // Arrange + await using var server = new InMemoryMcpServer(_ => { }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client); + ResetFeatureUsage(); + Assert.Null(GetFeatureToken()); + + // Act + _ = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Equal("v1.80000", GetFeatureToken()); + } + + public void Dispose() + { + ResetFeatureUsage(); + } + + private static string? GetFeatureToken() + => (string?)typeof(FeatureUsage) + .GetMethod("GetToken", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); + + private static void ResetFeatureUsage() + => typeof(FeatureUsage) + .GetMethod("ResetStateForTests", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); +} + +[CollectionDefinition(nameof(McpFeatureUsageActivationTestGroup), DisableParallelization = true)] +public sealed class McpFeatureUsageActivationTestGroup; diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..35c34d23f2e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Mem0.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + [Fact] + public async Task InvokingAsync_ActivatesMem0FeatureAsync() + { + // Arrange + using HttpClient httpClient = new(new SuccessfulSearchHandler()) + { + BaseAddress = new Uri("https://localhost/") + }; + var provider = new Mem0Provider( + httpClient, + static _ => new Mem0Provider.State(new Mem0ProviderScope { UserId = "user" })); + var context = new AIContextProvider.InvokingContext( + new Mock().Object, + new TestAgentSession(), + new AIContext { Messages = [new ChatMessage(ChatRole.User, "hello")] }); + FeatureUsageAssert.Reset(); + + // Act + _ = await provider.InvokingAsync(context); + + // Assert + FeatureUsageAssert.Marked(60); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private sealed class SuccessfulSearchHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]") + }); + } + + private sealed class TestAgentSession : AgentSession + { + public TestAgentSession() + { + this.StateBag = new AgentSessionStateBag(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj index 5abb64ca222..654e0edc681 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj @@ -12,4 +12,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..ae0411aba23 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; +using OpenAIChatClient = OpenAI.Chat.ChatClient; + +namespace Microsoft.Agents.AI.OpenAI.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + [Fact] + public async Task NonStreamingExecution_MarksOpenAIFeatureUsageAsync() + { + // Arrange + Mock innerClient = new(); + innerClient + .Setup(client => client.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + var agent = new TestOpenAIChatClient().AsAIAgent(clientFactory: _ => innerClient.Object); + FeatureUsageAssert.Reset(); + + // Act + _ = await agent.RunAsync("hello"); + + // Assert + FeatureUsageAssert.Marked(54); + } + + [Fact] + public async Task StreamingExecution_IsColdAndMarksOpenAIFeatureUsageAsync() + { + // Arrange + Mock innerClient = new(); + innerClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(EmptyUpdatesAsync()); + var agent = new TestOpenAIChatClient().AsAIAgent(clientFactory: _ => innerClient.Object); + FeatureUsageAssert.Reset(); + + // Act + IAsyncEnumerable updates = agent.RunStreamingAsync("hello"); + + // Assert + innerClient.Verify( + client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); + + await using IAsyncEnumerator enumerator = updates.GetAsyncEnumerator(); + Assert.False(await enumerator.MoveNextAsync()); + innerClient.Verify( + client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + FeatureUsageAssert.Marked(54); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private static async IAsyncEnumerable EmptyUpdatesAsync() + { + await Task.CompletedTask; + yield break; + } + + private sealed class TestOpenAIChatClient : OpenAIChatClient; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj index 515ca2fb8d7..f5d94683bc9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj @@ -4,4 +4,14 @@ + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/UserAgentPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/UserAgentPolicyTests.cs new file mode 100644 index 00000000000..4e9213d0e2c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/UserAgentPolicyTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using OpenAI.Chat; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.OpenAI.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class UserAgentPolicyTests : IDisposable +{ + private const string FeatureMaskDisabledEnvironmentVariable = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"; + + [Theory] + [InlineData("https://openai.azure.com/")] + [InlineData("https://resource.openai.azure.com/")] + [InlineData("https://cognitiveservices.azure.com/")] + [InlineData("https://resource.cognitiveservices.azure.com/")] + [InlineData("https://services.ai.azure.com/")] + [InlineData("https://project.services.ai.azure.com/")] + [InlineData("https://RESOURCE.OPENAI.AZURE.COM./")] + public void AzureOpenAIOrigin_AcceptsApprovedHttpsOrigins(string value) + { + // Arrange / Act + bool approved = OpenAIUserAgentPolicies.Registration.IsApprovedOrigin(new Uri(value)); + + // Assert + Assert.True(approved); + } + + [Theory] + [InlineData("http://resource.openai.azure.com/")] + [InlineData("https://resource.openai.azure.com.example.test/")] + [InlineData("https://inference.ai.azure.com/")] + [InlineData("https://api.openai.com/")] + [InlineData("https://example.com/")] + public void AzureOpenAIOrigin_RejectsNonAzureOpenAIOrigins(string value) + { + // Arrange / Act + bool approved = OpenAIUserAgentPolicies.Registration.IsApprovedOrigin(new Uri(value)); + + // Assert + Assert.False(approved); + } + + [Fact] + public async Task AzureOpenAIChatAgent_EmitsBaseAndFeatureUserAgentAsync() + { + // Arrange + using var handler = new RecordingHandler(); + AzureOpenAIClient client = CreateAzureOpenAIClient(handler); + ChatClientAgent agent = client.GetChatClient("deployment").AsAIAgent(); + FeatureUsageAssert.Reset(); + + // Act + _ = await Record.ExceptionAsync(() => agent.RunAsync("hello")); + + // Assert + AssertEligibleUserAgent(Assert.Single(handler.UserAgents)); + } + + [Fact] + public async Task AzureOpenAIResponsesAgent_EmitsBaseAndFeatureUserAgentAsync() + { + // Arrange + using var handler = new RecordingHandler(); + AzureOpenAIClient client = CreateAzureOpenAIClient(handler); + ChatClientAgent agent = client.GetResponsesClient().AsAIAgent(model: "deployment"); + FeatureUsageAssert.Reset(); + + // Act + _ = await Record.ExceptionAsync(() => agent.RunAsync("hello")); + + // Assert + AssertEligibleUserAgent(Assert.Single(handler.UserAgents)); + } + + [Fact] + public async Task AzureOpenAIChatAgent_DisabledMaskEmitsOnlyBaseUserAgentAsync() + { + // Arrange + string? originalValue = Environment.GetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable); + try + { + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, "true"); + FeatureUsageAssert.Reset(); + using var handler = new RecordingHandler(); + AzureOpenAIClient client = CreateAzureOpenAIClient(handler); + ChatClientAgent agent = client.GetChatClient("deployment").AsAIAgent(); + + // Act + _ = await Record.ExceptionAsync(() => agent.RunAsync("hello")); + + // Assert + string userAgent = Assert.IsType(Assert.Single(handler.UserAgents)); + Assert.Contains("agent-framework-dotnet/", userAgent, StringComparison.Ordinal); + Assert.DoesNotContain("(feat=", userAgent, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable, originalValue); + FeatureUsageAssert.Reset(); + } + } + + [Theory] + [InlineData("https://api.openai.com/v1")] + [InlineData("https://gateway.example.com/v1")] + [InlineData("http://resource.openai.azure.com/v1")] + [InlineData("https://resource.openai.azure.com.evil.test/v1")] + [InlineData("https://resource.inference.ai.azure.com/v1")] + public async Task IneligibleOpenAIChatAgent_DoesNotEmitAgentFrameworkUserAgentAsync(string endpoint) + { + // Arrange + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + var options = new global::OpenAI.OpenAIClientOptions + { + Endpoint = new Uri(endpoint), + Transport = new HttpClientPipelineTransport(httpClient), + }; + var client = new global::OpenAI.OpenAIClient(new ApiKeyCredential("test-key"), options); + ChatClientAgent agent = client.GetChatClient("model").AsAIAgent(); + FeatureUsageAssert.Reset(); + + // Act + _ = await Record.ExceptionAsync(() => agent.RunAsync("hello")); + + // Assert + string userAgent = Assert.IsType(Assert.Single(handler.UserAgents)); + Assert.DoesNotContain("agent-framework-dotnet/", userAgent, StringComparison.Ordinal); + Assert.DoesNotContain("(feat=", userAgent, StringComparison.Ordinal); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private static AzureOpenAIClient CreateAzureOpenAIClient(HttpMessageHandler handler) + { +#pragma warning disable CA5399 + var httpClient = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + return new AzureOpenAIClient( + new Uri("https://resource.openai.azure.com/"), + new ApiKeyCredential("test-key"), + new AzureOpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }); + } + + private static void AssertEligibleUserAgent(string? userAgent) + { + Assert.NotNull(userAgent); + Assert.Contains("agent-framework-dotnet/", userAgent, StringComparison.Ordinal); +#pragma warning disable MAAI001 + Assert.Contains(FeatureUsage.ApplyToUserAgent(string.Empty), userAgent, StringComparison.Ordinal); +#pragma warning restore MAAI001 + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List UserAgents { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + this.UserAgents.Add( + request.Headers.TryGetValues("User-Agent", out IEnumerable? values) + ? string.Join(" ", values) + : null); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..26c77cfd95d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.Purview.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + [Fact] + public async Task ProcessChatContentAsync_ActivatesPurviewFeatureAsync() + { + // Arrange + var processor = new Mock(); + processor + .Setup(p => p.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + Activity.UploadText, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user")); + var settings = new PurviewSettings("TestApp") + { + TenantId = "tenant", + PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app") + }; + var wrapper = new PurviewWrapper( + processor.Object, + settings, + NullLogger.Instance, + Mock.Of()); + FeatureUsageAssert.Reset(); + + // Act + _ = await wrapper.ProcessChatContentAsync( + [new ChatMessage(ChatRole.User, "hello")], + options: null, + Mock.Of(), + CancellationToken.None); + + // Assert + FeatureUsageAssert.Marked(61); + } + + public void Dispose() => FeatureUsageAssert.Reset(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj index 0129bba5d14..ea9236e2a02 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj @@ -4,5 +4,11 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..45700c7bbf4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Tools.Shell.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests +{ + [Fact] + public async Task RunAsync_MarksFeatureUsageAsync() + { + // Arrange + await using var shell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless }); + FeatureUsageAssert.Reset(); + + // Act + _ = await shell.RunAsync("echo feature-usage"); + + // Assert + FeatureUsageAssert.Marked(69); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj index f41ae11a6c4..2384ef46e92 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj @@ -9,4 +9,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/CopilotStudio/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/CopilotStudio/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..2a79be464bc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/CopilotStudio/FeatureUsageActivationTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.CopilotStudio; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.CopilotStudio; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() => FeatureUsageAssert.Reset(); + + [Fact] + public async Task RunAsync_WhenClientFails_ActivatesCopilotStudioAsync() + { + // Arrange + var agent = new CopilotStudioAgent(CreateTestCopilotClient(), NullLoggerFactory.Instance); + AgentSession session = await agent.CreateSessionAsync("conversation-id"); + + // Act + _ = await Assert.ThrowsAnyAsync(() => agent.RunAsync("hello", session)); + + // Assert + FeatureUsageAssert.Marked(56); + } + + [Fact] + public async Task RunStreamingAsync_IsColdAndActivatesCopilotStudioAsync() + { + // Arrange + var agent = new CopilotStudioAgent(CreateTestCopilotClient(), NullLoggerFactory.Instance); + AgentSession session = await agent.CreateSessionAsync("conversation-id"); + + // Act + IAsyncEnumerable stream = agent.RunStreamingAsync("hello", session); + + // Assert + Assert.Equal(string.Empty, FeatureUsage.ApplyToUserAgent(string.Empty)); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(); + _ = await Assert.ThrowsAnyAsync(async () => await enumerator.MoveNextAsync()); + FeatureUsageAssert.Marked(56); + } + + public void Dispose() => FeatureUsageAssert.Reset(); + + private static CopilotClient CreateTestCopilotClient() + { + Mock settings = new(); + Mock httpClientFactory = new(); + httpClientFactory + .Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(new Mock().Object); + + return new CopilotClient( + settings.Object, + httpClientFactory.Object, + NullLogger.Instance, + "test-client"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..ca5d702121a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +[Collection(nameof(FeatureUsageActivationTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + public FeatureUsageActivationTests() + { + ResetFeatureUsage(); + } + + [Fact] + public void Construction_DoesNotActivateCoreFeatures() + { + // Arrange + var agent = new TestAIAgent { NameFunc = () => "worker" }; + + // Act + _ = new ChatClientAgent(new Mock().Object, options: new() { ChatHistoryProvider = new NoOpChatHistoryProvider() }); + _ = new ToolApprovalAgent(agent); + _ = new FileMemoryProvider(new InMemoryAgentFileStore()); + _ = new FileAccessProvider(new InMemoryAgentFileStore()); + _ = new TextSearchProvider((_, _) => Task.FromResult>([])); + _ = new AgentSkillsProvider(new StaticSkillsSource([])); + _ = new CompactionProvider(new TruncationCompactionStrategy(_ => false)); + _ = new TodoProvider(); + _ = new AgentModeProvider(); + _ = new BackgroundAgentsProvider([agent]); + _ = new AgentFileSkillsSource(Path.Combine(AppContext.BaseDirectory, $"missing-skills-{Guid.NewGuid():N}"), (_, _, _, _, _) => Task.FromResult(null)); + _ = new AgentInMemorySkillsSource([]); + _ = new AgentInlineSkill("inline", "Inline skill.", "Instructions."); + _ = new TestClassSkill(); + + // Assert + Assert.Null(GetFeatureToken()); + } + + [Fact] + public async Task ChatClientAgent_NonStreamingExecution_ActivatesCoreAgentAsync() + { + // Arrange + Mock chatClient = new(); + chatClient + .Setup(client => client.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "done")])); + var agent = new ChatClientAgent(chatClient.Object, options: new() { ChatHistoryProvider = new NoOpChatHistoryProvider() }); + Assert.Null(GetFeatureToken()); + + // Act + _ = await agent.RunAsync("hello"); + + // Assert + Assert.Equal("v1.1", GetFeatureToken()); + } + + [Fact] + public async Task ChatClientAgent_StreamingExecution_IsColdUntilFirstEnumerationAsync() + { + // Arrange + Mock chatClient = new(); + chatClient + .Setup(client => client.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(EmptyChatResponseUpdatesAsync()); + var agent = new ChatClientAgent(chatClient.Object, options: new() { ChatHistoryProvider = new NoOpChatHistoryProvider() }); + + // Act + IAsyncEnumerable stream = agent.RunStreamingAsync("hello"); + + // Assert + Assert.Null(GetFeatureToken()); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(); + Assert.False(await enumerator.MoveNextAsync()); + Assert.Equal("v1.1", GetFeatureToken()); + } + + [Fact] + public async Task ToolApprovalAgent_Execution_ActivatesOnlyToolApprovalAsync() + { + // Arrange + var innerAgent = new TestAIAgent + { + RunAsyncFunc = (_, _, _, _) => Task.FromResult(new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")])) + }; + var agent = new ToolApprovalAgent(innerAgent); + + // Act + _ = await agent.RunAsync("hello", new TestAgentSession()); + + // Assert + Assert.Equal("v1.8", GetFeatureToken()); + } + + [Fact] + public async Task CoreProviders_ActivateTheirAssignedFeaturesAsync() + { + var agent = new TestAIAgent { NameFunc = () => "worker" }; + var session = new TestAgentSession(); + + await VerifyActivationAsync(4, async () => + { + Mock>> collection = new(); + Mock store = new(); + store.Setup(value => value.GetDynamicCollection( + It.IsAny(), + It.IsAny())).Returns(collection.Object); + var provider = new ChatHistoryMemoryProvider( + store.Object, + "memory", + 1, + _ => new ChatHistoryMemoryProvider.State(new ChatHistoryMemoryProviderScope { UserId = "user" }), + new ChatHistoryMemoryProviderOptions { SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling }); + await provider.InvokingAsync(CreateContext(agent, session)); + }); + await VerifyActivationAsync(5, async () => + await new FileMemoryProvider(new InMemoryAgentFileStore()).InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(6, async () => + await new TextSearchProvider( + (_, _) => Task.FromResult>([]), + new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling }) + .InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(7, async () => + await new FileAccessProvider(new InMemoryAgentFileStore()).InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(8, async () => + await new AgentSkillsProvider(new StaticSkillsSource([new StaticSkill()])).InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(9, async () => + await new CompactionProvider(new TruncationCompactionStrategy(_ => false)) + .InvokingAsync(CreateContext(agent, session, [new ChatMessage(ChatRole.User, "hello")]))); + await VerifyActivationAsync(10, async () => + await new TodoProvider().InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(11, async () => + await new AgentModeProvider().InvokingAsync(CreateContext(agent, session))); + await VerifyActivationAsync(12, async () => + await new BackgroundAgentsProvider([agent]).InvokingAsync(CreateContext(agent, session))); + } + + [Fact] + public async Task SkillSourcesAndProgrammaticSkills_ActivateOnLoadOrUseAsync() + { + await VerifyActivationAsync(15, async () => + { + var source = new AgentFileSkillsSource( + Path.Combine(AppContext.BaseDirectory, $"missing-skills-{Guid.NewGuid():N}"), + (_, _, _, _, _) => Task.FromResult(null)); + _ = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + }); + await VerifyActivationAsync(16, async () => + { + var source = new AgentInMemorySkillsSource([new StaticSkill()]); + _ = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + }); + await VerifyActivationAsync(17, async () => + { + var skill = new AgentInlineSkill("inline", "Inline skill.", "Instructions."); + _ = await skill.GetContentAsync(); + }); + await VerifyActivationAsync(18, async () => + { + var skill = new TestClassSkill(); + _ = await skill.GetContentAsync(); + }); + } + + public void Dispose() + { + ResetFeatureUsage(); + } + + private static AIContextProvider.InvokingContext CreateContext( + AIAgent agent, + AgentSession session, + IEnumerable? messages = null) + => new(agent, session, new AIContext { Messages = messages }); + + private static async Task VerifyActivationAsync(int index, Func action) + { + // Arrange + ResetFeatureUsage(); + Assert.Null(GetFeatureToken()); + + // Act + await action(); + + // Assert + Assert.Equal($"v1.{1L << index:x}", GetFeatureToken()); + } + + private static string? GetFeatureToken() + => (string?)typeof(FeatureUsage) + .GetMethod("GetToken", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); + + private static void ResetFeatureUsage() + => typeof(FeatureUsage) + .GetMethod("ResetStateForTests", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, null); + + private static async IAsyncEnumerable EmptyChatResponseUpdatesAsync() + { + await Task.CompletedTask; + yield break; + } + + private sealed class NoOpChatHistoryProvider : ChatHistoryProvider + { + protected override ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken = default) + => new([]); + + protected override ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken = default) + => default; + } + + private sealed class StaticSkillsSource(IList skills) : AgentSkillsSource + { + public override Task> GetSkillsAsync( + AgentSkillsSourceContext context, + CancellationToken cancellationToken = default) + => Task.FromResult(skills); + } + + private sealed class StaticSkill : AgentSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("static", "Static skill."); + + public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) + => new("content"); + } + + private sealed class TestClassSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("class", "Class skill."); + + protected override string Instructions => "Instructions."; + } + + private sealed class TestAgentSession : AgentSession; +} + +[CollectionDefinition(nameof(FeatureUsageActivationTestGroup), DisableParallelization = true)] +public sealed class FeatureUsageActivationTestGroup; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index 868dbcb2e07..e340d7cf044 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -28,4 +28,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/FeatureUsageActivationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/FeatureUsageActivationTests.cs new file mode 100644 index 00000000000..347821f8176 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/FeatureUsageActivationTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Moq; +using Valkey.Glide; + +namespace Microsoft.Agents.AI.Valkey.UnitTests; + +[Collection(nameof(FeatureUsageTestGroup))] +public sealed class FeatureUsageActivationTests : IDisposable +{ + [Fact] + public async Task InvokingAsync_ActivatesValkeyFeatureAsync() + { + // Arrange + var database = new Mock(); + database + .Setup(db => db.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + var connection = new Mock(); + connection.Setup(c => c.GetDatabase()).Returns(database.Object); + var provider = new ValkeyChatHistoryProvider( + connection.Object, + static _ => new ValkeyChatHistoryProvider.State("conversation")); + FeatureUsageAssert.Reset(); + + // Act + _ = await provider.InvokingAsync(TestHelpers.CreateChatHistoryInvokingContext()); + + // Assert + FeatureUsageAssert.Marked(59); + } + + public void Dispose() => FeatureUsageAssert.Reset(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj index 4dd4ff1f6c8..2ebcb1c1432 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj @@ -8,4 +8,10 @@ + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs index 64e14d66ac6..09e2e393d49 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Concurrent; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; +using System.Numerics; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Observability; @@ -14,6 +17,9 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; +[CollectionDefinition("DeclarativeWorkflowOptionsTest", DisableParallelization = true)] +public sealed class DeclarativeWorkflowOptionsTestScope; + /// /// Tests for telemetry configuration. /// @@ -49,7 +55,7 @@ public DeclarativeWorkflowOptionsTest() ShouldListenTo = source => source.Name == DefaultTelemetrySourceName || source.Name == "TestSource", - Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + Sample = (ref options) => ActivitySamplingResult.AllData, ActivityStarted = activity => this._capturedActivities.Add(activity), }; ActivitySource.AddActivityListener(this._activityListener); @@ -245,6 +251,60 @@ public async Task BuildWorkflow_WithoutTelemetry_DoesNotCreateActivitiesAsync() Assert.Empty(capturedActivities); } + [Fact] + public void BuildWorkflow_MarksDeclarativeWorkflowAfterSuccessfulBuild() + { + // Arrange + ResetFeatureUsage(); + DeclarativeWorkflowOptions options = new(CreateMockProvider().Object); + + // Act + using StringReader reader = new(SimpleWorkflowYaml); + _ = DeclarativeWorkflowBuilder.Build(reader, options); + + // Assert + BigInteger declarativeWorkflow = BigInteger.One << (int)FeatureIndex.DeclarativeWorkflow; + Assert.NotEqual(BigInteger.Zero, GetFeatureMask() & declarativeWorkflow); + } + + [Fact] + public void BuildWorkflow_WhenDefinitionIsInvalid_DoesNotMarkDeclarativeWorkflow() + { + // Arrange + ResetFeatureUsage(); + DeclarativeWorkflowOptions options = new(CreateMockProvider().Object); + + // Act + using StringReader reader = new(string.Empty); + void build() => DeclarativeWorkflowBuilder.Build(reader, options); + + // Assert + Assert.Throws(build); + Assert.Equal(BigInteger.Zero, GetFeatureMask()); + } + + private static BigInteger GetFeatureMask() + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent("test"); +#pragma warning restore MAAI001 + const string Prefix = "test (feat=v1."; + if (userAgent == "test") + { + return BigInteger.Zero; + } + + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + string mask = userAgent.Substring(Prefix.Length, userAgent.Length - Prefix.Length - 1); + return BigInteger.Parse($"0{mask}", NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + } + + private static void ResetFeatureUsage() + => typeof(FeatureUsage) + .GetMethod("ResetStateForTests", BindingFlags.Static | BindingFlags.NonPublic)! + .Invoke(null, null); + private static Mock CreateMockProvider() { Mock mockAgentProvider = new(MockBehavior.Strict); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs index dfeeaea510a..88664d4d404 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs @@ -2,6 +2,9 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Numerics; +using System.Reflection; using FluentAssertions; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -311,8 +314,8 @@ public void AddExternalCall_CreatesRequestPortAndRoundTripEdges() // Assert workflow.Ports.Should().ContainKey(PortId); - workflow.Ports[PortId].Request.Should().Be(typeof(string)); - workflow.Ports[PortId].Response.Should().Be(typeof(int)); + workflow.Ports[PortId].Request.Should().Be(); + workflow.Ports[PortId].Response.Should().Be(); workflow.ExecutorBindings.Should().ContainKey(PortId); Edge requestEdge = GetSingleEdge(workflow, source.Id); @@ -482,7 +485,7 @@ public void Test_WithIntermediateOutputFrom_AddsIntermediateTag() .WithIntermediateOutputFrom([b]) .Build(); - workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate }); + workflow.OutputExecutors["b"].Should().BeEquivalentTo([OutputTag.Intermediate]); } [Fact] @@ -514,7 +517,7 @@ public void Test_WithOutputFrom_ThenIntermediate_AccumulatesTags() .Build(); // WithOutputFrom doesn't add a tag; WithIntermediateOutputFrom adds Intermediate. - workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate }); + workflow.OutputExecutors["b"].Should().BeEquivalentTo([OutputTag.Intermediate]); } [Fact] @@ -528,7 +531,7 @@ public void Test_WithIntermediateOutputFrom_RepeatedDedupes() .WithIntermediateOutputFrom([b]) .Build(); - workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate }); + workflow.OutputExecutors["b"].Should().BeEquivalentTo([OutputTag.Intermediate]); } [Fact] @@ -544,7 +547,7 @@ public void Test_WithIntermediateOutputFrom_OnlyRegistersWithoutPriorWithOutputF .Build(); workflow.OutputExecutors.Should().ContainKey("b"); - workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate }); + workflow.OutputExecutors["b"].Should().BeEquivalentTo([OutputTag.Intermediate]); } [Fact] @@ -561,6 +564,118 @@ public void Test_WithOutputFrom_TracksExecutorBinding() .Build(); workflow.OutputExecutors.Should().ContainKey("future"); - workflow.OutputExecutors["future"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate }); + workflow.OutputExecutors["future"].Should().BeEquivalentTo([OutputTag.Intermediate]); } } + +[CollectionDefinition("WorkflowFeatureUsage", DisableParallelization = true)] +public sealed class WorkflowFeatureUsageScope; + +[Collection("WorkflowFeatureUsage")] +public sealed class WorkflowFeatureUsageTests +{ + private sealed class NoOpExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => + routeBuilder.AddHandler((message, context) => context.SendMessageAsync(message))); + } + + [Fact] + public void Build_MarksOnlyTheActivatedWorkflowFeature() + { + // Arrange + ResetFeatureUsage(); + WorkflowBuilder custom = new(new NoOpExecutor("custom")); + OrchestrationTestHelpers.DoubleEchoAgent sequentialAgent = new("sequential"); + OrchestrationTestHelpers.DoubleEchoAgent concurrentAgent = new("concurrent"); + OrchestrationTestHelpers.DoubleEchoAgent groupChatAgent = new("group-chat"); + OrchestrationTestHelpers.DoubleEchoAgent magenticManager = new("magentic-manager"); + OrchestrationTestHelpers.DoubleEchoAgent magenticAgent = new("magentic-agent"); + OrchestrationTestHelpers.DoubleEchoAgent handoffCoordinator = new("handoff-coordinator"); + OrchestrationTestHelpers.DoubleEchoAgent handoffSpecialist = new("handoff-specialist"); + + SequentialWorkflowBuilder sequential = new(sequentialAgent); + ConcurrentWorkflowBuilder concurrent = new(concurrentAgent); + GroupChatWorkflowBuilder groupChat = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents)) + .AddParticipants(groupChatAgent); + MagenticWorkflowBuilder magentic = new MagenticWorkflowBuilder(magenticManager) + .AddParticipants(magenticAgent) + .RequirePlanSignoff(false); + HandoffWorkflowBuilder handoff = AgentWorkflowBuilder + .CreateHandoffBuilderWith(handoffCoordinator) + .WithHandoff(handoffCoordinator, handoffSpecialist); + + (string Name, int FeatureIndex, Func Build)[] specialized = + [ + ("sequential", (int)FeatureIndex.OrchestrationSequential, sequential.Build), + ("concurrent", (int)FeatureIndex.OrchestrationConcurrent, concurrent.Build), + ("group chat", (int)FeatureIndex.OrchestrationGroupChat, groupChat.Build), + ("magentic", (int)FeatureIndex.OrchestrationMagentic, magentic.Build), + ("handoff", (int)FeatureIndex.OrchestrationHandoff, handoff.Build), + ]; + + // Act and assert + Assert.Equal(BigInteger.Zero, GetFeatureMask()); + + _ = custom.Build(); + AssertFeatureMask(FeatureIndex.CoreWorkflow, "custom"); + + foreach ((string name, int featureIndex, Func build) in specialized) + { + ResetFeatureUsage(); + _ = build(); + AssertFeatureMask(featureIndex, name); + } + } + + [Fact] + public void Build_WhenValidationFails_DoesNotMarkCoreWorkflow() + { + // Arrange + ResetFeatureUsage(); + WorkflowBuilder builder = new("unbound"); + + // Act + Action build = () => builder.Build(); + + // Assert + build.Should().Throw(); + Assert.Equal(BigInteger.Zero, GetFeatureMask()); + } + + private static void AssertFeatureMask(FeatureIndex featureIndex, string workflowKind) + => AssertFeatureMask((int)featureIndex, workflowKind); + + private static void AssertFeatureMask(int featureIndex, string workflowKind) + { + BigInteger expected = BigInteger.One << featureIndex; + BigInteger actual = GetFeatureMask(); + Assert.True( + actual == expected, + $"Expected {workflowKind} workflow build to mark only feature bit {featureIndex}, but found mask 0x{actual:x}."); + } + + private static BigInteger GetFeatureMask() + { +#pragma warning disable MAAI001 + string userAgent = FeatureUsage.ApplyToUserAgent("test"); +#pragma warning restore MAAI001 + const string Prefix = "test (feat=v1."; + if (userAgent == "test") + { + return BigInteger.Zero; + } + + Assert.StartsWith(Prefix, userAgent); + Assert.EndsWith(")", userAgent); + string mask = userAgent.Substring(Prefix.Length, userAgent.Length - Prefix.Length - 1); + return BigInteger.Parse($"0{mask}", NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + } + + private static void ResetFeatureUsage() + => typeof(FeatureUsage) + .GetMethod("ResetStateForTests", BindingFlags.Static | BindingFlags.NonPublic)! + .Invoke(null, null); +}