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