Rename AI Agent packages to use Microsoft.Agents.AI (#913)

* Rename AI Agent packages to use Microsoft.Agents.AI

* Fix for build

* Fix formatting

* Fix formatting

* Ignore in VSTHRD200 in migration samples

* Ignore in VSTHRD200 in migration samples

* Add some missing projects and run format

* Fix build errors

* Address code review feedback

* Fix merge issues

---------

Co-authored-by: Mark Wallace <markwallace@microsoft.com>
This commit is contained in:
Mark Wallace
2025-09-25 20:31:25 +01:00
committed by GitHub
Unverified
parent a480ccfd16
commit 32e054f1fe
332 changed files with 520 additions and 432 deletions
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Extension methods for <see cref="AIAgent"/>.
/// </summary>
public static class AgentExtensions
{
/// <summary>
/// Wraps the agent with OpenTelemetry instrumentation.
/// </summary>
/// <param name="agent">The agent to wrap.</param>
/// <param name="loggerFactory">The <see cref="ILogger"/> to use for emitting events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
/// <param name="enableSensitiveData">When <see langword="true"/> indicates whether potentially sensitive information should be included in telemetry. Default is <see langword="false"/></param>
/// <returns>An <see cref="OpenTelemetryAgent"/> that wraps the original agent with telemetry.</returns>
public static OpenTelemetryAgent WithOpenTelemetry(this AIAgent agent, ILoggerFactory? loggerFactory = null, string? sourceName = null, bool? enableSensitiveData = null) =>
new(agent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName)
{
EnableSensitiveData = enableSensitiveData ?? false
};
/// <summary>
/// Creates a <see cref="AIFunction"/> that will invoke the provided Agent.
/// </summary>
/// <param name="agent">The <see cref="AIAgent" /> to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="agent"/>.</param>
/// <param name="thread">The <see cref="AgentThread"/> to use for the function.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking the <see cref="AIAgent"/>.</returns>
public static AIFunction AsAIFunction(this AIAgent agent, AIFunctionFactoryOptions? options = null, AgentThread? thread = null)
{
Throw.IfNull(agent);
[Description("Invoke an agent to retrieve some information.")]
async Task<string> InvokeAgentAsync(
[Description("Input query to invoke the agent.")] string query,
CancellationToken cancellationToken)
{
var response = await agent.RunAsync(query, thread: thread, cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Text;
}
options ??= new();
options.Name ??= agent.Name;
options.Description ??= agent.Description;
return AIFunctionFactory.Create(InvokeAgentAsync, options);
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agents.</summary>
public static partial class AgentJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from Microsoft.Extensions.AI.Abstractions.
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// Agent abstraction types
[JsonSerializable(typeof(ChatClientAgentThread.ThreadState))]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Provides extensions for configuring <see cref="AgentInvokedChatClient"/> instances.</summary>
public static class AgentChatClientBuilderExtensions
{
/// <summary>
/// Enables automatic function call invocation on the chat pipeline.
/// </summary>
/// <remarks>This works by adding an instance of <see cref="AgentInvokedChatClient"/> with default options.</remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> being used to build the chat pipeline.</param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static ChatClientBuilder UseAgentInvocation(
this ChatClientBuilder builder)
{
_ = Throw.IfNull(builder);
return builder.Use((innerClient, services) =>
new AgentInvokedChatClient(innerClient));
}
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal chat client that handle agent invocation details for the chat client pipeline.
/// </summary>
internal sealed class AgentInvokedChatClient : DelegatingChatClient
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentInvokedChatClient"/> class.
/// </summary>
/// <param name="chatClient">The chat client to invoke agents.</param>
internal AgentInvokedChatClient(IChatClient chatClient)
: base(chatClient)
{
}
}
@@ -0,0 +1,541 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
#pragma warning disable S3358 // Ternary operators should not be nested
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents an agent that can be invoked using a chat client.
/// </summary>
public sealed class ChatClientAgent : AIAgent
{
private readonly ChatClientAgentOptions? _agentOptions;
private readonly AIAgentMetadata _agentMetadata;
private readonly ILogger _logger;
private readonly Type _chatClientType;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgent"/> class.
/// </summary>
/// <param name="chatClient">The chat client to use for invoking the agent.</param>
/// <param name="instructions">Optional instructions for the agent.</param>
/// <param name="name">Optional name for the agent.</param>
/// <param name="description">Optional description for the agent.</param>
/// <param name="tools">Optional list of tools that the agent can use during invocation.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public ChatClientAgent(IChatClient chatClient, string? instructions = null, string? name = null, string? description = null, IList<AITool>? tools = null, ILoggerFactory? loggerFactory = null)
: this(
chatClient,
new ChatClientAgentOptions
{
Name = name,
Description = description,
Instructions = instructions,
ChatOptions = tools is null ? null : new ChatOptions
{
Tools = tools,
}
},
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgent"/> class.
/// </summary>
/// <param name="chatClient">The chat client to use for invoking the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(chatClient);
// Options must be cloned since ChatClientAgentOptions is mutable.
this._agentOptions = options?.Clone();
this._agentMetadata = new AIAgentMetadata(chatClient.GetService<ChatClientMetadata>()?.ProviderName);
// Get the type of the chat client before wrapping it as an agent invoking chat client.
this._chatClientType = chatClient.GetType();
// If the user has not opted out of using our default decorators, we wrap the chat client.
this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.AsAgentInvokedChatClient(options);
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
}
/// <summary>
/// Gets the underlying chat client used by the agent to invoke chat completions.
/// </summary>
public IChatClient ChatClient { get; }
/// <inheritdoc/>
public override string Id => this._agentOptions?.Id ?? base.Id;
/// <inheritdoc/>
public override string? Name => this._agentOptions?.Name;
/// <inheritdoc/>
public override string? Description => this._agentOptions?.Description;
/// <summary>
/// Gets the instructions for the agent (optional).
/// </summary>
public string? Instructions => this._agentOptions?.Instructions;
/// <summary>
/// Gets of the default <see cref="ChatOptions"/> used by the agent.
/// </summary>
internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions;
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var agentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
// Call the IChatClient and notify the AIContextProvider of any failures.
ChatResponse chatResponse;
try
{
chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType, inputMessages.Count);
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
{
chatResponseMessage.AuthorName ??= agentName;
chatResponseMessage.MessageId ??= Guid.NewGuid().ToString("N");
chatResponseMessage.CreatedAt ??= DateTimeOffset.UtcNow;
}
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
return new(chatResponse) { AgentId = this.Id };
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
int messageCount = threadMessages.Count;
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> responseUpdatesEnumerator;
try
{
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
bool hasUpdates;
try
{
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
string? messageId = null;
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
update.CreatedAt ??= DateTimeOffset.UtcNow;
update.MessageId ??= (messageId ??= Guid.NewGuid().ToString("N"));
responseUpdates.Add(update);
yield return new(update) { AgentId = this.Id };
}
try
{
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata
: serviceType == typeof(IChatClient) ? this.ChatClient
: this.ChatClient.GetService(serviceType, serviceKey));
/// <inheritdoc/>
public override AgentThread GetNewThread()
=> new ChatClientAgentThread
{
MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
};
/// <summary>
/// Get a new <see cref="AgentThread"/> instance using an existing conversation id, to continue that conversation.
/// </summary>
/// <param name="conversationId">The conversation id to continue.</param>
/// <returns>A new <see cref="AgentThread"/> instance.</returns>
/// <remarks>
/// Note that any <see cref="AgentThread"/> created with this method will only work with <see cref="ChatClientAgent"/> instances that support storing
/// chat history in the underlying service provided by the <see cref="IChatClient"/>.
/// </remarks>
public AgentThread GetNewThread(string conversationId)
=> new ChatClientAgentThread()
{
ConversationId = conversationId,
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
};
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
Func<JsonElement, JsonSerializerOptions?, ChatMessageStore>? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ?
null :
(jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = this._agentOptions?.AIContextProviderFactory is null ?
null :
(jse, jso) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
return new ChatClientAgentThread(
serializedThread,
jsonSerializerOptions,
chatMessageStoreFactory,
aiContextProviderFactory);
}
#region Private
/// <summary>
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { ResponseMessages = responseMessages },
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { InvokeException = ex },
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Configures and returns chat options by merging the provided run options with the agent's default chat options.
/// </summary>
/// <remarks>This method prioritizes the chat options provided in <paramref name="runOptions"/> over the
/// agent's default chat options. Any unset properties in the run options will be filled using the agent's chat
/// options. If both are <see langword="null"/>, the method returns <see langword="null"/>.</remarks>
/// <param name="runOptions">Optional run options that may include specific chat configuration settings.</param>
/// <returns>A <see cref="ChatOptions"/> object representing the merged chat configuration, or <see langword="null"/> if
/// neither the run options nor the agent's chat options are available.</returns>
private ChatOptions? CreateConfiguredChatOptions(AgentRunOptions? runOptions)
{
ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone();
// If no agent chat options were provided, return the request chat options as is.
if (this._agentOptions?.ChatOptions is null)
{
return requestChatOptions;
}
// If no request chat options were provided, use the agent's chat options clone.
if (requestChatOptions is null)
{
return this._agentOptions?.ChatOptions.Clone();
}
// If both are present, we need to merge them.
// The merge strategy will prioritize the request options over the agent options,
// and will fill the blanks with agent options where the request options were not set.
requestChatOptions.AllowMultipleToolCalls ??= this._agentOptions.ChatOptions.AllowMultipleToolCalls;
requestChatOptions.ConversationId ??= this._agentOptions.ChatOptions.ConversationId;
requestChatOptions.FrequencyPenalty ??= this._agentOptions.ChatOptions.FrequencyPenalty;
requestChatOptions.Instructions ??= this._agentOptions.ChatOptions.Instructions;
requestChatOptions.MaxOutputTokens ??= this._agentOptions.ChatOptions.MaxOutputTokens;
requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId;
requestChatOptions.PresencePenalty ??= this._agentOptions.ChatOptions.PresencePenalty;
requestChatOptions.ResponseFormat ??= this._agentOptions.ChatOptions.ResponseFormat;
requestChatOptions.Seed ??= this._agentOptions.ChatOptions.Seed;
requestChatOptions.Temperature ??= this._agentOptions.ChatOptions.Temperature;
requestChatOptions.TopP ??= this._agentOptions.ChatOptions.TopP;
requestChatOptions.TopK ??= this._agentOptions.ChatOptions.TopK;
requestChatOptions.ToolMode ??= this._agentOptions.ChatOptions.ToolMode;
// Merge only the additional properties from the agent if they are not already set in the request options.
if (requestChatOptions.AdditionalProperties is not null && this._agentOptions.ChatOptions.AdditionalProperties is not null)
{
foreach (var propertyKey in this._agentOptions.ChatOptions.AdditionalProperties.Keys)
{
_ = requestChatOptions.AdditionalProperties.TryAdd(propertyKey, this._agentOptions.ChatOptions.AdditionalProperties[propertyKey]);
}
}
else
{
requestChatOptions.AdditionalProperties ??= this._agentOptions.ChatOptions.AdditionalProperties?.Clone();
}
// Chain the raw representation factory from the request options with the agent's factory if available.
if (this._agentOptions.ChatOptions.RawRepresentationFactory is { } agentFactory)
{
requestChatOptions.RawRepresentationFactory = requestChatOptions.RawRepresentationFactory is { } requestFactory
? chatClient => requestFactory(chatClient) ?? agentFactory(chatClient)
: agentFactory;
}
// We concatenate the request stop sequences with the agent's stop sequences when available.
if (this._agentOptions.ChatOptions.StopSequences is { Count: not 0 })
{
if (requestChatOptions.StopSequences is null || requestChatOptions.StopSequences.Count == 0)
{
// If the request stop sequences are not set or empty, we use the agent's stop sequences directly.
requestChatOptions.StopSequences = [.. this._agentOptions.ChatOptions.StopSequences];
}
else if (requestChatOptions.StopSequences is List<string> requestStopSequences)
{
// If the request stop sequences are set, we concatenate them with the agent's stop sequences.
requestStopSequences.AddRange(this._agentOptions.ChatOptions.StopSequences);
}
else
{
// If both agent's and request's stop sequences are set, we concatenate them.
foreach (string stopSequence in this._agentOptions.ChatOptions.StopSequences)
{
requestChatOptions.StopSequences.Add(stopSequence);
}
}
}
// We concatenate the request tools with the agent's tools when available.
if (this._agentOptions.ChatOptions.Tools is { Count: not 0 })
{
if (requestChatOptions.Tools is not { Count: > 0 })
{
// If the request tools are not set or empty, we use the agent's tools.
requestChatOptions.Tools = [.. this._agentOptions.ChatOptions.Tools];
}
else
{
if (requestChatOptions.Tools is List<AITool> requestTools)
{
// If the request tools are set, we concatenate them with the agent's tools.
requestTools.AddRange(this._agentOptions.ChatOptions.Tools);
}
else
{
// If the both agent's and request's tools are set, we concatenate all tools.
foreach (var tool in this._agentOptions.ChatOptions.Tools)
{
requestChatOptions.Tools.Add(tool);
}
}
}
}
return requestChatOptions;
}
/// <summary>
/// Prepares the thread, chat options, and messages for agent execution.
/// </summary>
/// <param name="thread">The conversation thread to use or create.</param>
/// <param name="inputMessages">The input messages to use.</param>
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> ThreadMessages)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable<ChatMessage> inputMessages,
AgentRunOptions? runOptions,
CancellationToken cancellationToken)
{
ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
thread ??= this.GetNewThread();
if (thread is not ChatClientAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
// Add any existing messages from the thread to the messages to be sent to the chat client.
List<ChatMessage> threadMessages = [];
if (typedThread.MessageStore is not null)
{
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
}
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (typedThread.AIContextProvider is not null)
{
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
{
threadMessages.AddRange(aiContext.Messages);
}
if (aiContext.Tools is { Count: > 0 })
{
chatOptions ??= new();
chatOptions.Tools ??= [];
foreach (AITool tool in aiContext.Tools)
{
chatOptions.Tools.Add(tool);
}
}
if (aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}";
}
}
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
// If a user provided two different thread ids, via the thread object and options, we should throw
// since we don't know which one to use.
if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && typedThread.ConversationId != chatOptions!.ConversationId)
{
throw new InvalidOperationException(
$"""
The {nameof(chatOptions.ConversationId)} provided via {nameof(Extensions.AI.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}.
Only one id can be used for a run.
""");
}
if (!string.IsNullOrWhiteSpace(this.Instructions))
{
chatOptions ??= new();
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? this.Instructions : $"{this.Instructions}\n{chatOptions.Instructions}";
}
// Only create or update ChatOptions if we have an id on the thread and we don't have the same one already in ChatOptions.
if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && typedThread.ConversationId != chatOptions?.ConversationId)
{
chatOptions ??= new();
chatOptions.ConversationId = typedThread.ConversationId;
}
return (typedThread, chatOptions, threadMessages);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
{
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(thread.ConversationId))
{
// We were passed a thread that is service managed, but we got no conversation id back from the chat client,
// meaning the service doesn't support service managed threads, so the thread cannot be used with this service.
throw new InvalidOperationException("Service did not return a valid conversation id when using a service managed thread.");
}
if (!string.IsNullOrWhiteSpace(responseConversationId))
{
// If we got a conversation id back from the chat client, it means that the service supports server side thread storage
// so we should update the thread with the new id.
thread.ConversationId = responseConversationId;
}
else
{
// If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
// the thread has no MessageStore yet, and we have a custom messages store, we should update the thread
// with the custom MessageStore so that it has somewhere to store the chat history.
thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null });
}
}
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
#endregion
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI;
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
/// <summary>
/// Extensions for logging <see cref="ChatClientAgent"/> invocations.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class ChatClientAgentLogMessages
{
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoking agent (started).
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoking client {ClientType}.")]
public static partial void LogAgentChatClientInvokingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type clientType);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoked agent (complete).
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked client {ClientType} with message count: {MessageCount}.")]
public static partial void LogAgentChatClientInvokedAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type clientType,
int messageCount);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> invoked streaming agent (complete).
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "[{MethodName}] Agent {AgentId}/{AgentName} Invoked client {ClientType}.")]
public static partial void LogAgentChatClientInvokedStreamingAgent(
this ILogger logger,
string methodName,
string agentId,
string agentName,
Type clientType);
}
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents metadata for a chat client agent, including its identifier, name, instructions, and description.
/// </summary>
/// <remarks>
/// This class is used to encapsulate information about a chat client agent, such as its unique
/// identifier, display name, operational instructions, and a descriptive summary. It can be used to store and transfer
/// agent-related metadata within a chat application.
/// </remarks>
public class ChatClientAgentOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentOptions"/> class.
/// </summary>
public ChatClientAgentOptions()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentOptions"/> class with the specified parameters.
/// </summary>
/// <remarks>If <paramref name="tools"/> is provided, a new <see cref="ChatOptions"/> instance is created
/// with the specified instructions and tools.</remarks>
/// <param name="instructions">The instructions or guidelines for the chat client agent. Can be <see langword="null"/> if not specified.</param>
/// <param name="name">The name of the chat client agent. Can be <see langword="null"/> if not specified.</param>
/// <param name="description">The description of the chat client agent. Can be <see langword="null"/> if not specified.</param>
/// <param name="tools">A list of <see cref="AITool"/> instances available to the chat client agent. Can be <see langword="null"/> if no
/// tools are specified.</param>
public ChatClientAgentOptions(string? instructions, string? name = null, string? description = null, IList<AITool>? tools = null)
{
this.Name = name;
this.Instructions = instructions;
this.Description = description;
if (tools is not null)
{
(this.ChatOptions ??= new()).Tools = tools;
}
if (instructions is not null)
{
(this.ChatOptions ??= new()).Instructions = instructions;
}
}
/// <summary>
/// Gets or sets the agent id.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Gets or sets the agent name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the agent instructions.
/// </summary>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the default chatOptions to use.
/// </summary>
public ChatOptions? ChatOptions { get; set; }
/// <summary>
/// Gets or sets a factory function to create an instance of <see cref="ChatMessageStore"/>
/// which will be used to store chat messages for this agent.
/// </summary>
public Func<ChatMessageStoreFactoryContext, ChatMessageStore>? ChatMessageStoreFactory { get; set; }
/// <summary>
/// Gets or sets a factory function to create an instance of <see cref="AIContextProvider"/>
/// which will be used to create a context provider for each new thread, and can then
/// provide additional context for each agent run.
/// </summary>
public Func<AIContextProviderFactoryContext, AIContextProvider>? AIContextProviderFactory { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
/// without applying any default decorators.
/// </summary>
/// <remarks>
/// By default the <see cref="ChatClientAgent"/> applies decorators to the provided <see cref="IChatClient"/>
/// for doing for example automatic function invocation. Setting this property to <see langword="true"/>
/// disables adding these default decorators.
/// Disabling is recommended if you want to decorate the <see cref="IChatClient"/> with different decorators
/// than the default ones. The provided <see cref="IChatClient"/> instance should then already be decorated
/// with the desired decorators.
/// </remarks>
public bool UseProvidedChatClientAsIs { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
internal ChatClientAgentOptions Clone()
=> new()
{
Id = this.Id,
Name = this.Name,
Instructions = this.Instructions,
Description = this.Description,
ChatOptions = this.ChatOptions?.Clone(),
ChatMessageStoreFactory = this.ChatMessageStoreFactory,
AIContextProviderFactory = this.AIContextProviderFactory,
};
/// <summary>
/// Context object passed to the <see cref="AIContextProviderFactory"/> to create a new instance of <see cref="AIContextProvider"/>.
/// </summary>
public class AIContextProviderFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the <see cref="AIContextProvider"/>, if any.
/// </summary>
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="AIContextProvider"/> is first created.</value>
public JsonElement SerializedState { get; set; }
/// <summary>
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
}
/// <summary>
/// Context object passed to the <see cref="ChatMessageStoreFactory"/> to create a new instance of <see cref="ChatMessageStore"/>.
/// </summary>
public class ChatMessageStoreFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the chat message store, if any.
/// </summary>
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="ChatMessageStore"/> is first created.</value>
public JsonElement SerializedState { get; set; }
/// <summary>
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Chat client agent run options.
/// </summary>
public sealed class ChatClientAgentRunOptions : AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class.
/// </summary>
/// <param name="chatOptions">Optional chat options to pass to the agent's invocation.</param>
public ChatClientAgentRunOptions(ChatOptions? chatOptions = null)
{
this.ChatOptions = chatOptions;
}
/// <summary>Gets or sets optional chat options to pass to the agent's invocation.</summary>
public ChatOptions? ChatOptions { get; set; }
}
@@ -0,0 +1,221 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Thread for ChatClient based agents.
/// </summary>
public class ChatClientAgentThread : AgentThread
{
private string? _conversationId;
private ChatMessageStore? _messageStore;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentThread"/> class.
/// </summary>
internal ChatClientAgentThread()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentThread"/> class from serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="chatMessageStoreFactory">An optional factory function to create a custom <see cref="ChatMessageStore"/>.</param>
/// <param name="aiContextProviderFactory">An optional factory function to create a custom <see cref="AIContextProvider"/>.</param>
internal ChatClientAgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, ChatMessageStore>? chatMessageStoreFactory = null,
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = null)
{
if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = serializedThreadState.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState;
this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions);
if (state?.ConversationId is string threadId)
{
this.ConversationId = threadId;
// Since we have an ID, we should not have a chat message store and we can return here.
return;
}
this._messageStore =
chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions) ??
new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); // default to an in-memory store
}
/// <summary>
/// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="MessageStore "/> is not null, and <see cref="ConversationId"/> is set, <see cref="MessageStore "/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages via the <see cref="ChatMessageStore"/> and not in the agent service.</item>
/// <item>This thread object is new and a server managed thread has not yet been created in the agent service.</item>
/// </list>
/// </para>
/// <para>
/// The id may also change over time where the id is pointing at a
/// agent service managed thread, and the default behavior of a service is
/// to fork the thread with each iteration.
/// </para>
/// </remarks>
public string? ConversationId
{
get => this._conversationId;
internal set
{
if (string.IsNullOrWhiteSpace(this._conversationId) && string.IsNullOrWhiteSpace(value))
{
return;
}
if (this._messageStore is not null)
{
// If we have a message store already, we shouldn't switch the thread to use a conversation id
// since it means that the thread contents will essentially be deleted, and the thread will not work
// with the original agent anymore.
throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.");
}
this._conversationId = Throw.IfNullOrWhitespace(value);
}
}
/// <summary>
/// Gets or sets the <see cref="ChatMessageStore"/> used by this thread, for cases where messages should be stored in a custom location.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="ConversationId"/> is not null, and <see cref="MessageStore "/> is set, <see cref="ConversationId"/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages in the agent service and just has an id to the remove thread, instead of in an <see cref="ChatMessageStore"/>.</item>
/// <item>This thread object is new it is not yet clear whether it will be backed by a server managed thread or an <see cref="ChatMessageStore"/>.</item>
/// </list>
/// </para>
/// </remarks>
public ChatMessageStore? MessageStore
{
get => this._messageStore;
internal set
{
if (this._messageStore is null && value is null)
{
return;
}
if (!string.IsNullOrWhiteSpace(this._conversationId))
{
// If we have a conversation id already, we shouldn't switch the thread to use a message store
// since it means that the thread will not work with the original agent anymore.
throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.");
}
this._messageStore = Throw.IfNull(value);
}
}
/// <summary>
/// Gets or sets the <see cref="AIContextProvider"/> used by this thread to provide additional context to the AI model before each invocation.
/// </summary>
public AIContextProvider? AIContextProvider { get; internal set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public override async Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
var storeState = this._messageStore is null ?
null :
await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var aiContextProviderState = this.AIContextProvider is null ?
null :
await this.AIContextProvider.SerializeAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var state = new ThreadState
{
ConversationId = this.ConversationId,
StoreState = storeState,
AIContextProviderState = aiContextProviderState
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState)));
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType == typeof(AgentThreadMetadata)
? new AgentThreadMetadata(this.ConversationId)
: base.GetService(serviceType, serviceKey)
?? this.AIContextProvider?.GetService(serviceType, serviceKey)
?? this.MessageStore?.GetService(serviceType, serviceKey);
/// <inheritdoc />
protected override async Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
{
switch (this)
{
case { ConversationId: not null }:
// If the thread messages are stored in the service
// there is nothing to do here, since invoking the
// service should already update the thread.
break;
case { MessageStore: null }:
// If there is no conversation id, and no store we can createa a default in memory store and add messages to it.
this._messageStore = new InMemoryChatMessageStore();
await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false);
break;
case { MessageStore: not null }:
// If a store has been provided, we need to add the messages to the store.
await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false);
break;
default:
throw new UnreachableException();
}
}
internal sealed class ThreadState
{
public string? ConversationId { get; set; }
public JsonElement? StoreState { get; set; }
public JsonElement? AIContextProviderState { get; set; }
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI;
internal static class ChatClientExtensions
{
internal static IChatClient AsAgentInvokedChatClient(this IChatClient chatClient, ChatClientAgentOptions? options)
{
var chatBuilder = chatClient.AsBuilder();
// AgentInvokingChatClient should be the outermost decorator
if (chatClient is not AgentInvokedChatClient agentInvokingChatClient)
{
chatBuilder.UseAgentInvocation();
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
_ = chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService<ILoggerFactory>();
return new FunctionInvokingChatClient(innerClient, loggerFactory, services);
});
}
var agentChatClient = chatBuilder.Build();
if (options?.ChatOptions?.Tools is { Count: > 0 })
{
// When tools are provided in the constructor, set the tools for the whole lifecycle of the chat client
var functionService = agentChatClient.GetService<FunctionInvokingChatClient>();
Debug.Assert(functionService is not null, "FunctionInvokingChatClient should be registered in the chat client.");
functionService!.AdditionalTools = options.ChatOptions.Tools;
}
return agentChatClient;
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Extensions AI Agent Framework</Title>
<Description>Contains the Microsoft Agent Framework core functionality.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,534 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a delegating agent that implements OpenTelemetry instrumentation for agent operations.
/// </summary>
/// <remarks>
/// This class provides telemetry instrumentation for agent operations including activities, metrics, and logging.
/// The telemetry output follows OpenTelemetry semantic conventions in <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/"/> and is subject to change as the conventions evolve.
/// </remarks>
public sealed partial class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
private const LogLevel EventLogLevel = LogLevel.Information;
private JsonSerializerOptions _jsonSerializerOptions;
private readonly OpenTelemetryChatClient? _openTelemetryChatClient;
private readonly string? _system;
private readonly ActivitySource _activitySource;
private readonly Meter _meter;
private readonly Histogram<double> _operationDurationHistogram;
private readonly Histogram<int> _tokenUsageHistogram;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to wrap with telemetry.</param>
/// <param name="logger">The <see cref="ILogger"/> to use for emitting events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, string? sourceName = null)
: base(innerAgent)
{
string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!;
this._activitySource = new(name);
this._meter = new(name);
this._logger = logger ?? NullLogger.Instance;
this._system = this.GetService<AIAgentMetadata>()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftExtensionsAIAgents;
// Attempt to get the open telemetry chat client if the inner agent is a ChatClientAgent.
this._openTelemetryChatClient = (this.InnerAgent as ChatClientAgent)?.ChatClient.GetService<OpenTelemetryChatClient>();
// Inherit by default the EnableSensitiveData setting from the TelemetryChatClient if available.
this.EnableSensitiveData = this._openTelemetryChatClient?.EnableSensitiveData ?? false;
this._operationDurationHistogram = this._meter.CreateHistogram<double>(
OpenTelemetryConsts.GenAI.Client.OperationDuration.Name,
OpenTelemetryConsts.SecondsUnit,
OpenTelemetryConsts.GenAI.Client.OperationDuration.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }
#endif
);
this._tokenUsageHistogram = this._meter.CreateHistogram<int>(
OpenTelemetryConsts.GenAI.Client.TokenUsage.Name,
OpenTelemetryConsts.TokensUnit,
OpenTelemetryConsts.GenAI.Client.TokenUsage.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries }
#endif
);
this._jsonSerializerOptions = AIJsonUtilities.DefaultOptions;
}
/// <summary>Gets or sets JSON serialization options to use when formatting chat data into telemetry strings.</summary>
public JsonSerializerOptions JsonSerializerOptions
{
get => this._jsonSerializerOptions;
set => this._jsonSerializerOptions = Throw.IfNull(value);
}
/// <summary>
/// Disposes the telemetry resources.
/// </summary>
public void Dispose()
{
this._activitySource.Dispose();
this._meter.Dispose();
}
/// <summary>
/// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry.
/// </summary>
/// <value>
/// <see langword="true"/> if potentially sensitive information should be included in telemetry;
/// <see langword="false"/> if telemetry shouldn't include raw inputs and outputs.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// By default, telemetry includes metadata, such as token counts, but not raw inputs
/// and outputs, such as message content, function call arguments, and function call results.
/// </remarks>
public bool EnableSensitiveData { get; set; }
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
// Handle ActivitySource requests directly - always return our own ActivitySource
if (serviceType == typeof(ActivitySource))
{
return this._activitySource;
}
// For other service types, use the base delegation logic
return base.GetService(serviceType, serviceKey);
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
this.LogChatMessages(inputMessages);
AgentRunResponse? response = null;
Exception? error = null;
try
{
response = await base.RunAsync(inputMessages, thread, options, cancellationToken).ConfigureAwait(false);
return response;
}
catch (Exception ex)
{
error = ex;
throw;
}
finally
{
this.TraceResponse(activity, response, error, stopwatch);
}
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
IAsyncEnumerable<AgentRunResponseUpdate> updates;
try
{
updates = base.RunStreamingAsync(inputMessages, thread, options, cancellationToken);
}
catch (Exception ex)
{
this.TraceResponse(activity, response: null, ex, stopwatch);
throw;
}
var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken);
List<AgentRunResponseUpdate> trackedUpdates = [];
Exception? error = null;
try
{
while (true)
{
AgentRunResponseUpdate update;
try
{
if (!await responseEnumerator.MoveNextAsync().ConfigureAwait(false))
{
break;
}
update = responseEnumerator.Current;
}
catch (Exception ex)
{
error = ex;
throw;
}
trackedUpdates.Add(update);
yield return update;
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
}
}
finally
{
this.TraceResponse(activity, trackedUpdates.ToAgentRunResponse(), error, stopwatch);
await responseEnumerator.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Creates an activity for an agent request, or returns null if not enabled.
/// </summary>
private Activity? CreateAndConfigureActivity(string operationName, AgentThread? thread)
{
// Get the GenAI system name for telemetry
var chatClientAgent = this.InnerAgent as ChatClientAgent;
Activity? activity = null;
if (this._activitySource.HasListeners())
{
string activityName = string.IsNullOrWhiteSpace(this.Name) ? operationName : $"{operationName} {this.Name}";
activity = this._activitySource.StartActivity(activityName, ActivityKind.Client);
if (activity is not null)
{
_ = activity
// Required attributes per OpenTelemetry semantic conventions
.AddTag(OpenTelemetryConsts.GenAI.Operation.Name, operationName)
.AddTag(OpenTelemetryConsts.GenAI.SystemName, this._system)
// Agent-specific attributes
.AddTag(OpenTelemetryConsts.GenAI.Agent.Id, this.Id);
// Add agent name if available (following gen_ai.agent.name convention - conditionally required when available)
if (!string.IsNullOrWhiteSpace(this.Name))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
}
// Add description if available (following gen_ai.agent.description convention)
if (!string.IsNullOrWhiteSpace(this.Description))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Description, this.Description);
}
// Add conversation ID if thread is available (following gen_ai.conversation.id convention)
var metadata = thread?.GetService<AgentThreadMetadata>();
if (!string.IsNullOrWhiteSpace(metadata?.ConversationId))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, metadata.ConversationId);
}
// Add instructions if available (for ChatClientAgent)
if (!string.IsNullOrWhiteSpace(chatClientAgent?.Instructions))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Instructions, chatClientAgent.Instructions);
}
}
}
return activity;
}
/// <summary>
/// Adds a tag to the tag list if the value is not null or whitespace.
/// </summary>
private static void AddIfNotWhiteSpace(ref TagList tags, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
tags.Add(key, value);
}
}
/// <summary>
/// Adds agent response information to the activity and records metrics.
/// </summary>
private void TraceResponse(
Activity? activity,
AgentRunResponse? response,
Exception? error,
Stopwatch? stopwatch)
{
// Record operation duration metric
if (this._operationDurationHistogram.Enabled && stopwatch is not null)
{
TagList tags = new()
{
{ OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent }
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.DisplayName);
if (error is not null)
{
tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName);
}
this._operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
// Record token usage metrics
if (this._tokenUsageHistogram.Enabled && response?.Usage is { } usage)
{
if (usage.InputTokenCount is long inputTokens)
{
TagList tags = new()
{
{ OpenTelemetryConsts.GenAI.Token.Type, "input" }
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)inputTokens, tags);
}
if (usage.OutputTokenCount is long outputTokens)
{
TagList tags = new()
{
{ OpenTelemetryConsts.GenAI.Token.Type, "output" }
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)outputTokens, tags);
}
}
// Add activity tags
if (activity is not null)
{
if (error is not null)
{
_ = activity
.AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName)
.SetStatus(ActivityStatusCode.Error, error.Message);
}
if (response is not null)
{
if (!string.IsNullOrWhiteSpace(response.ResponseId))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId);
}
if (response.Usage?.InputTokenCount is long inputTokens)
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens);
}
if (response.Usage?.OutputTokenCount is long outputTokens)
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens);
}
}
}
// Log the agent response for choice events
if (response is not null)
{
this.LogAgentResponse(response);
}
}
private void LogChatMessages(IEnumerable<ChatMessage> messages)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled,
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
foreach (ChatMessage message in messages)
{
if (message.Role == ChatRole.Assistant)
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Assistant.Message),
JsonSerializer.Serialize(this.CreateAssistantEvent(message.Contents), OtelContext.Default.AssistantEvent));
}
else if (message.Role == ChatRole.Tool)
{
foreach (FunctionResultContent frc in message.Contents.OfType<FunctionResultContent>())
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Tool.Message),
JsonSerializer.Serialize(new ToolEvent()
{
Id = frc.CallId,
Content = this.EnableSensitiveData && frc.Result is object result ?
JsonSerializer.SerializeToNode(result, this._jsonSerializerOptions.GetTypeInfo(result.GetType())) :
null,
}, OtelContext.Default.ToolEvent));
}
}
else
{
this.Log(new EventId(1, message.Role == ChatRole.System ? OpenTelemetryConsts.GenAI.System.Message : OpenTelemetryConsts.GenAI.User.Message),
JsonSerializer.Serialize(new SystemOrUserEvent()
{
Role = message.Role != ChatRole.System && message.Role != ChatRole.User && !string.IsNullOrWhiteSpace(message.Role.Value) ? message.Role.Value : null,
Content = this.GetMessageContent(message.Contents),
}, OtelContext.Default.SystemOrUserEvent));
}
}
}
private void LogAgentResponse(AgentRunResponse response)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
EventId id = new(1, OpenTelemetryConsts.GenAI.Choice);
this.Log(id, JsonSerializer.Serialize(new ChoiceEvent()
{
FinishReason = (response.RawRepresentation as ChatResponse)?.FinishReason?.Value ?? string.Empty,
Index = 0,
Message = this.CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)),
}, OtelContext.Default.ChoiceEvent));
}
private void Log(EventId id, string eventBodyJson)
{
// This is not the idiomatic way to log, but it's necessary for now in order to structure
// the data in a way that the OpenTelemetry collector can work with it. The event body
// can be very large and should not be logged as an attribute.
KeyValuePair<string, object?>[] tags =
[
new(OpenTelemetryConsts.Event.Name, id.Name),
new(OpenTelemetryConsts.GenAI.SystemName, this._system),
];
this._logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson);
}
private AssistantEvent CreateAssistantEvent(IEnumerable<AIContent> contents)
{
var toolCalls = contents.OfType<FunctionCallContent>().Select(fc => new ToolCall
{
Id = fc.CallId,
Function = new()
{
Name = fc.Name,
Arguments = this.EnableSensitiveData ?
JsonSerializer.SerializeToNode(fc.Arguments, this._jsonSerializerOptions.GetTypeInfo(typeof(IDictionary<string, object?>))) :
null,
},
}).ToArray();
return new()
{
Content = this.GetMessageContent(contents),
ToolCalls = toolCalls.Length > 0 ? toolCalls : null,
};
}
private string? GetMessageContent(IEnumerable<AIContent> contents)
{
if (this.EnableSensitiveData)
{
string content = string.Concat(contents.OfType<TextContent>());
if (content.Length > 0)
{
return content;
}
}
return null;
}
private sealed partial class SystemOrUserEvent
{
public string? Role { get; set; }
public string? Content { get; set; }
}
private sealed class AssistantEvent
{
public string? Content { get; set; }
public ToolCall[]? ToolCalls { get; set; }
}
private sealed partial class ToolEvent
{
public string? Id { get; set; }
public JsonNode? Content { get; set; }
}
private sealed partial class ChoiceEvent
{
public string? FinishReason { get; set; }
public int Index { get; set; }
public AssistantEvent? Message { get; set; }
}
private sealed partial class ToolCall
{
public string? Id { get; set; }
public string? Type { get; set; } = "function";
public ToolCallFunction? Function { get; set; }
}
private sealed partial class ToolCallFunction
{
public string? Name { get; set; }
public JsonNode? Arguments { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(SystemOrUserEvent))]
[JsonSerializable(typeof(AssistantEvent))]
[JsonSerializable(typeof(ToolEvent))]
[JsonSerializable(typeof(ChoiceEvent))]
[JsonSerializable(typeof(object))]
private sealed partial class OtelContext : JsonSerializerContext;
}
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
/// <summary>Provides constants used by various telemetry services.</summary>
internal static class OpenTelemetryConsts
{
public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI.Agents";
public const string SecondsUnit = "s";
public const string TokensUnit = "token";
public static class Event
{
public const string Name = "event.name";
}
public static class Error
{
public const string Type = "error.type";
}
public static class GenAI
{
public const string Choice = "gen_ai.choice";
public const string SystemName = "gen_ai.system";
public static class SystemNameValues
{
public const string MicrosoftExtensionsAIAgents = "microsoft.extensions.ai.agents";
}
public const string Chat = "chat";
public const string Embeddings = "embeddings";
public const string ExecuteTool = "execute_tool";
public static class Agent
{
public const string Id = "gen_ai.agent.id";
public const string Name = "gen_ai.agent.name";
public const string Description = "gen_ai.agent.description";
}
public static class Assistant
{
public const string Message = "gen_ai.assistant.message";
}
public static class Client
{
public static class OperationDuration
{
public const string Description = "Measures the duration of a GenAI operation";
public const string Name = "gen_ai.client.operation.duration";
public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92];
}
public static class TokenUsage
{
public const string Description = "Measures number of input and output tokens used";
public const string Name = "gen_ai.client.token.usage";
public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864];
}
}
public static class Conversation
{
public const string Id = "gen_ai.conversation.id";
}
public static class Operation
{
public const string Name = "gen_ai.operation.name";
public static class NameValues
{
public const string InvokeAgent = "invoke_agent";
}
}
public static class Output
{
public const string Type = "gen_ai.output.type";
}
public static class Request
{
public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions";
public const string FrequencyPenalty = "gen_ai.request.frequency_penalty";
public const string Model = "gen_ai.request.model";
public const string MaxTokens = "gen_ai.request.max_tokens";
public const string PresencePenalty = "gen_ai.request.presence_penalty";
public const string Seed = "gen_ai.request.seed";
public const string StopSequences = "gen_ai.request.stop_sequences";
public const string Temperature = "gen_ai.request.temperature";
public const string TopK = "gen_ai.request.top_k";
public const string TopP = "gen_ai.request.top_p";
// Not available in OTEL : Potential proposals
public const string Instructions = "gen_ai.request.instructions";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}";
}
public static class Response
{
public const string FinishReasons = "gen_ai.response.finish_reasons";
public const string Id = "gen_ai.response.id";
public const string Model = "gen_ai.response.model";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
}
public static class System
{
public const string Message = "gen_ai.system.message";
}
public static class Token
{
public const string Type = "gen_ai.token.type";
}
public static class Tool
{
public const string Name = "gen_ai.tool.name";
public const string Description = "gen_ai.tool.description";
public const string Message = "gen_ai.tool.message";
public static class Call
{
public const string Id = "gen_ai.tool.call.id";
}
}
public static class Usage
{
public const string InputTokens = "gen_ai.usage.input_tokens";
public const string OutputTokens = "gen_ai.usage.output_tokens";
}
public static class User
{
public const string Message = "gen_ai.user.message";
}
}
public static class Server
{
public const string Address = "server.address";
public const string Port = "server.port";
}
}