mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
a480ccfd16
commit
32e054f1fe
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user