mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add LoggingAgent wrapper for ILogger-based observability (#2701)
* Initial plan * Add LoggingAgent class and UseLogging extension method Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Add unit tests for LoggingAgent and fix JSON serialization error handling Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Add comments explaining unreachable code in test async iterators Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Fix file encoding - add UTF-8 BOM to all C# files Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Address Format issues * Addres format * Break up extensions in dedicated files * Adjust class names * Add xmldoc info --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
1949193a2e
commit
90964acd2d
@@ -10,7 +10,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using ChatClient = OpenAI.Chat.ChatClient;
|
||||
|
||||
namespace AGUIDojoServer;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring and customizing <see cref="AIAgentBuilder"/> instances.
|
||||
/// </summary>
|
||||
public static class FunctionInvocationDelegatingAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds function invocation callbacks to the <see cref="AIAgent"/> pipeline that intercepts and processes <see cref="AIFunction"/> calls.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which the function invocation callback is added.</param>
|
||||
/// <param name="callback">
|
||||
/// A delegate that processes function invocations. The delegate receives the <see cref="AIAgent"/> instance,
|
||||
/// the function invocation context, and a continuation delegate representing the next callback in the pipeline.
|
||||
/// It returns a task representing the result of the function invocation.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> instance with the function invocation callback added, enabling method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="callback"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The callback must call the provided continuation delegate to proceed with the function invocation,
|
||||
/// unless it intends to completely replace the function's behavior.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The inner agent or the pipeline wrapping it must include a <see cref="FunctionInvokingChatClient"/>. If one does not exist,
|
||||
/// the <see cref="AIAgent"/> added to the pipline by this method will throw an exception when it is invoked.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder Use(this AIAgentBuilder builder, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> callback)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
_ = Throw.IfNull(callback);
|
||||
return builder.Use((innerAgent, _) =>
|
||||
{
|
||||
// Function calling requires a ChatClientAgent inner agent.
|
||||
if (innerAgent.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients.");
|
||||
}
|
||||
|
||||
return new FunctionInvocationDelegatingAgent(innerAgent, callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Shared.Diagnostics;
|
||||
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating AI agent that logs agent operations to an <see cref="ILogger"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The provided implementation of <see cref="AIAgent"/> is thread-safe for concurrent use so long as the
|
||||
/// <see cref="ILogger"/> employed is also thread-safe for concurrent use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the employed <see cref="ILogger"/> enables <see cref="LogLevel.Trace"/>, the contents of
|
||||
/// messages, options, and responses are logged. These may contain sensitive application data.
|
||||
/// <see cref="LogLevel.Trace"/> is disabled by default and should never be enabled in a production environment.
|
||||
/// Messages and options are not logged at other logging levels.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class LoggingAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>An <see cref="ILogger"/> instance used for all logging.</summary>
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>The <see cref="JsonSerializerOptions"/> to use for serialization of state written to the logger.</summary>
|
||||
private JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LoggingAgent"/> class.</summary>
|
||||
/// <param name="innerAgent">The underlying <see cref="AIAgent"/>.</param>
|
||||
/// <param name="logger">An <see cref="ILogger"/> instance that will be used for all logging.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> or <paramref name="logger"/> is <see langword="null"/>.</exception>
|
||||
public LoggingAgent(AIAgent innerAgent, ILogger logger)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._logger = Throw.IfNull(logger);
|
||||
this._jsonSerializerOptions = AgentJsonUtilities.DefaultOptions;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets JSON serialization options to use when serializing logging data.</summary>
|
||||
public JsonSerializerOptions JsonSerializerOptions
|
||||
{
|
||||
get => this._jsonSerializerOptions;
|
||||
set => this._jsonSerializerOptions = Throw.IfNull(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
this.LogInvokedSensitive(nameof(RunAsync), this.AsJson(messages), this.AsJson(options), this.AsJson(this.GetService<AIAgentMetadata>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.LogInvoked(nameof(RunAsync));
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AgentRunResponse response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
this.LogCompletedSensitive(nameof(RunAsync), this.AsJson(response));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.LogCompleted(nameof(RunAsync));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.LogInvocationCanceled(nameof(RunAsync));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.LogInvocationFailed(nameof(RunAsync), ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
this.LogInvokedSensitive(nameof(RunStreamingAsync), this.AsJson(messages), this.AsJson(options), this.AsJson(this.GetService<AIAgentMetadata>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.LogInvoked(nameof(RunStreamingAsync));
|
||||
}
|
||||
}
|
||||
|
||||
IAsyncEnumerator<AgentRunResponseUpdate> e;
|
||||
try
|
||||
{
|
||||
e = base.RunStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.LogInvocationCanceled(nameof(RunStreamingAsync));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.LogInvocationFailed(nameof(RunStreamingAsync), ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AgentRunResponseUpdate? update = null;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await e.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
update = e.Current;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.LogInvocationCanceled(nameof(RunStreamingAsync));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.LogInvocationFailed(nameof(RunStreamingAsync), ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
this.LogStreamingUpdateSensitive(this.AsJson(update));
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
|
||||
this.LogCompleted(nameof(RunStreamingAsync));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await e.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private string AsJson<T>(T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(value, this._jsonSerializerOptions.GetTypeInfo(typeof(T)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If serialization fails, return a simple string representation
|
||||
return value?.ToString() ?? "null";
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")]
|
||||
private partial void LogInvoked(string methodName);
|
||||
|
||||
[LoggerMessage(LogLevel.Trace, "{MethodName} invoked: {Messages}. Options: {Options}. Metadata: {Metadata}.")]
|
||||
private partial void LogInvokedSensitive(string methodName, string messages, string options, string metadata);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "{MethodName} completed.")]
|
||||
private partial void LogCompleted(string methodName);
|
||||
|
||||
[LoggerMessage(LogLevel.Trace, "{MethodName} completed: {Response}.")]
|
||||
private partial void LogCompletedSensitive(string methodName, string response);
|
||||
|
||||
[LoggerMessage(LogLevel.Trace, "RunStreamingAsync received update: {Update}")]
|
||||
private partial void LogStreamingUpdateSensitive(string update);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")]
|
||||
private partial void LogInvocationCanceled(string methodName);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "{MethodName} failed.")]
|
||||
private partial void LogInvocationFailed(string methodName, Exception error);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding logging support to <see cref="AIAgentBuilder"/> instances.
|
||||
/// </summary>
|
||||
public static class LoggingAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds logging to the agent pipeline, enabling detailed observability of agent operations.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which logging support will be added.</param>
|
||||
/// <param name="loggerFactory">
|
||||
/// An optional <see cref="ILoggerFactory"/> used to create a logger with which logging should be performed.
|
||||
/// If not supplied, a required instance will be resolved from the service provider.
|
||||
/// </param>
|
||||
/// <param name="configure">
|
||||
/// An optional callback that provides additional configuration of the <see cref="LoggingAgent"/> instance.
|
||||
/// This allows for fine-tuning logging behavior such as customizing JSON serialization options.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> with logging support added, enabling method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When the employed <see cref="ILogger"/> enables <see cref="LogLevel.Trace"/>, the contents of
|
||||
/// messages, options, and responses are logged. These may contain sensitive application data.
|
||||
/// <see cref="LogLevel.Trace"/> is disabled by default and should never be enabled in a production environment.
|
||||
/// Messages and options are not logged at other logging levels.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the resolved or provided <see cref="ILoggerFactory"/> is <see cref="NullLoggerFactory"/>, this will be a no-op where
|
||||
/// logging will be effectively disabled. In this case, the <see cref="LoggingAgent"/> will not be added.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder UseLogging(
|
||||
this AIAgentBuilder builder,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
Action<LoggingAgent>? configure = null)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
|
||||
return builder.Use((innerAgent, services) =>
|
||||
{
|
||||
loggerFactory ??= services.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
// If the factory we resolve is for the null logger, the LoggingAgent will end up
|
||||
// being an expensive nop, so skip adding it and just return the inner agent.
|
||||
if (loggerFactory == NullLoggerFactory.Instance)
|
||||
{
|
||||
return innerAgent;
|
||||
}
|
||||
|
||||
LoggingAgent agent = new(innerAgent, loggerFactory.CreateLogger(nameof(LoggingAgent)));
|
||||
configure?.Invoke(agent);
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+2
-42
@@ -1,55 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring and customizing <see cref="AIAgentBuilder"/> instances.
|
||||
/// Provides extension methods for adding OpenTelemetry instrumentation to <see cref="AIAgentBuilder"/> instances.
|
||||
/// </summary>
|
||||
public static class AIAgentBuilderExtensions
|
||||
public static class OpenTelemetryAgentBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds function invocation callbacks to the <see cref="AIAgent"/> pipeline that intercepts and processes <see cref="AIFunction"/> calls.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which the function invocation callback is added.</param>
|
||||
/// <param name="callback">
|
||||
/// A delegate that processes function invocations. The delegate receives the <see cref="AIAgent"/> instance,
|
||||
/// the function invocation context, and a continuation delegate representing the next callback in the pipeline.
|
||||
/// It returns a task representing the result of the function invocation.
|
||||
/// </param>
|
||||
/// <returns>The <see cref="AIAgentBuilder"/> instance with the function invocation callback added, enabling method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="callback"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The callback must call the provided continuation delegate to proceed with the function invocation,
|
||||
/// unless it intends to completely replace the function's behavior.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The inner agent or the pipeline wrapping it must include a <see cref="FunctionInvokingChatClient"/>. If one does not exist,
|
||||
/// the <see cref="AIAgent"/> added to the pipline by this method will throw an exception when it is invoked.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder Use(this AIAgentBuilder builder, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> callback)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
_ = Throw.IfNull(callback);
|
||||
return builder.Use((innerAgent, _) =>
|
||||
{
|
||||
// Function calling requires a ChatClientAgent inner agent.
|
||||
if (innerAgent.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients.");
|
||||
}
|
||||
|
||||
return new FunctionInvocationDelegatingAgent(innerAgent, callback);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds OpenTelemetry instrumentation to the agent pipeline, enabling comprehensive observability for agent operations.
|
||||
/// </summary>
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="LoggingAgentBuilderExtensions"/> UseLogging extension method.
|
||||
/// </summary>
|
||||
public class LoggingAgentBuilderExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that UseLogging throws ArgumentNullException when builder is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithNullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseLogging());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging returns a LoggingAgent when logger factory is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithLoggerFactory_ReturnsLoggingAgent()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging(loggerFactory: loggerFactory).Build();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<LoggingAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging returns the inner agent when NullLoggerFactory is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithNullLoggerFactory_ReturnsInnerAgent()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging(loggerFactory: NullLoggerFactory.Instance).Build();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsNotType<LoggingAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging with configure action works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithConfigureAction_CallsConfigureAction()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
var configureWasCalled = false;
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging(
|
||||
loggerFactory: loggerFactory,
|
||||
configure: agent =>
|
||||
{
|
||||
configureWasCalled = true;
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<LoggingAgent>(agent);
|
||||
}).Build();
|
||||
|
||||
// Assert
|
||||
Assert.True(configureWasCalled);
|
||||
Assert.IsType<LoggingAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging returns the same builder instance for chaining.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_ReturnsBuilderForChaining()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
|
||||
// Act
|
||||
AIAgentBuilder result = builder.UseLogging(loggerFactory: loggerFactory);
|
||||
|
||||
// Assert
|
||||
Assert.Same(builder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging with all parameters works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithAllParameters_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
var configureWasCalled = false;
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging(
|
||||
loggerFactory: loggerFactory,
|
||||
configure: agent =>
|
||||
{
|
||||
configureWasCalled = true;
|
||||
Assert.NotNull(agent);
|
||||
}).Build();
|
||||
|
||||
// Assert
|
||||
Assert.True(configureWasCalled);
|
||||
Assert.IsType<LoggingAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging resolves ILoggerFactory from service provider when not provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_WithoutLoggerFactory_ResolvesFromServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
services.AddSingleton(loggerFactory);
|
||||
|
||||
builder.Use((innerAgent, serviceProvider) =>
|
||||
{
|
||||
Assert.NotNull(serviceProvider);
|
||||
return innerAgent;
|
||||
});
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging().Build(services.BuildServiceProvider());
|
||||
|
||||
// Assert
|
||||
Assert.IsType<LoggingAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that UseLogging with configure action can customize JsonSerializerOptions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseLogging_ConfigureJsonSerializerOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
using var loggerFactory = LoggerFactory.Create(builder => { });
|
||||
var customOptions = new System.Text.Json.JsonSerializerOptions();
|
||||
|
||||
// Act
|
||||
AIAgent result = builder.UseLogging(
|
||||
loggerFactory: loggerFactory,
|
||||
configure: agent => agent.JsonSerializerOptions = customOptions).Build();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<LoggingAgent>(result);
|
||||
Assert.Same(customOptions, ((LoggingAgent)result).JsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="LoggingAgent"/> class.
|
||||
/// </summary>
|
||||
public class LoggingAgentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ctor_InvalidArgs_Throws()
|
||||
{
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
Assert.Throws<ArgumentNullException>("innerAgent", () => new LoggingAgent(null!, mockLogger.Object));
|
||||
Assert.Throws<ArgumentNullException>("logger", () => new LoggingAgent(new TestAIAgent(), null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_DelegateToInnerAgent()
|
||||
{
|
||||
// Arrange
|
||||
TestAIAgent innerAgent = new()
|
||||
{
|
||||
NameFunc = () => "TestAgent",
|
||||
DescriptionFunc = () => "This is a test agent.",
|
||||
};
|
||||
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("This is a test agent.", agent.Description);
|
||||
Assert.Equal(innerAgent.Id, agent.Id);
|
||||
Assert.Equal(innerAgent.DisplayName, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializerOptions_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
|
||||
JsonSerializerOptions options = new();
|
||||
|
||||
// Act
|
||||
agent.JsonSerializerOptions = options;
|
||||
|
||||
// Assert
|
||||
Assert.Same(options, agent.JsonSerializerOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializerOptions_SetNull_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agent.JsonSerializerOptions = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_LogsAtDebugLevelAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
|
||||
}
|
||||
};
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_LogsAtTraceLevel_IncludesSensitiveDataAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
|
||||
}
|
||||
};
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_OnCancellation_LogsCanceledAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = (messages, thread, options, cancellationToken) =>
|
||||
throw new OperationCanceledException()
|
||||
};
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => agent.RunAsync(messages));
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_OnException_LogsFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = (messages, thread, options, cancellationToken) =>
|
||||
throw new InvalidOperationException("Test exception")
|
||||
};
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(messages));
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_LogsAtDebugLevelAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunStreamingAsyncFunc = CallbackAsync
|
||||
};
|
||||
|
||||
static async IAsyncEnumerable<AgentRunResponseUpdate> CallbackAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Test");
|
||||
}
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
await foreach (var update in agent.RunStreamingAsync(messages))
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Assert
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync invoked")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync completed")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_LogsUpdatesAtTraceLevelAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunStreamingAsyncFunc = CallbackAsync
|
||||
};
|
||||
|
||||
static async IAsyncEnumerable<AgentRunResponseUpdate> CallbackAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Update 1");
|
||||
yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Update 2");
|
||||
}
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
await foreach (var update in agent.RunStreamingAsync(messages))
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Assert
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("received update")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_OnCancellation_LogsCanceledAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunStreamingAsyncFunc = CallbackAsync
|
||||
};
|
||||
|
||||
static async IAsyncEnumerable<AgentRunResponseUpdate> CallbackAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new OperationCanceledException();
|
||||
// The following yield statement is required for async iterator methods but is unreachable.
|
||||
// This pattern is intentional for testing exception scenarios in async iterators.
|
||||
#pragma warning disable CS0162 // Unreachable code detected
|
||||
yield break;
|
||||
#pragma warning restore CS0162 // Unreachable code detected
|
||||
}
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(messages))
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
});
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_OnException_LogsFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
|
||||
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
|
||||
|
||||
var innerAgent = new TestAIAgent
|
||||
{
|
||||
RunStreamingAsyncFunc = CallbackAsync
|
||||
};
|
||||
|
||||
static async IAsyncEnumerable<AgentRunResponseUpdate> CallbackAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("Test exception");
|
||||
// The following yield statement is required for async iterator methods but is unreachable.
|
||||
// This pattern is intentional for testing exception scenarios in async iterators.
|
||||
#pragma warning disable CS0162 // Unreachable code detected
|
||||
yield break;
|
||||
#pragma warning restore CS0162 // Unreachable code detected
|
||||
}
|
||||
|
||||
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(messages))
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
});
|
||||
|
||||
mockLogger.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ using Moq;
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgentBuilderExtensions"/> class.
|
||||
/// Unit tests for the <see cref="OpenTelemetryAgentBuilderExtensions"/> class.
|
||||
/// </summary>
|
||||
public class OpenTelemetryAIAgentBuilderExtensionsTests
|
||||
public class OpenTelemetryAgentBuilderExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that UseOpenTelemetry throws ArgumentNullException when builder is null.
|
||||
Reference in New Issue
Block a user