diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs new file mode 100644 index 0000000000..e2e0fd7f2d --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building agents that can be chained around an underlying . +/// The default implementation simply passes each call to the inner agent instance. +/// +public class DelegatingAIAgent : AIAgent +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped agent instance. + protected DelegatingAIAgent(AIAgent innerAgent) + { + this.InnerAgent = Throw.IfNull(innerAgent); + } + + /// Gets the inner . + protected AIAgent InnerAgent { get; } + + /// + public override string Id => this.InnerAgent.Id; + + /// + public override string? Name => this.InnerAgent.Name; + + /// + public override string? Description => this.InnerAgent.Description; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + this.InnerAgent.GetService(serviceType, serviceKey); + } + + /// + public override AgentThread GetNewThread() => this.InnerAgent.GetNewThread(); + + /// + public override Task RunAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.InnerAgent.RunAsync(messages, thread, options, cancellationToken); + + /// + public override IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs index fd81f7cd3e..7f8b46c82b 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs @@ -24,13 +24,12 @@ namespace Microsoft.Extensions.AI.Agents; /// This class provides telemetry instrumentation for agent operations including activities, metrics, and logging. /// The telemetry output follows OpenTelemetry semantic conventions in and is subject to change as the conventions evolve. /// -public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable +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 AIAgent _innerAgent; private readonly ActivitySource _activitySource; private readonly Meter _meter; private readonly Histogram _operationDurationHistogram; @@ -44,9 +43,8 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable /// The to use for emitting events. /// An optional source name that will be used on the telemetry data. public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, string? sourceName = null) + : base(innerAgent) { - this._innerAgent = Throw.IfNull(innerAgent); - string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; this._activitySource = new(name); this._meter = new(name); @@ -54,7 +52,7 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable this._system = this.GetService()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftExtensionsAIAgents; // Attempt to get the open telemetry chat client if the inner agent is a ChatClientAgent. - this._openTelemetryChatClient = (innerAgent as ChatClientAgent)?.ChatClient.GetService(); + this._openTelemetryChatClient = (this.InnerAgent as ChatClientAgent)?.ChatClient.GetService(); // Inherit by default the EnableSensitiveData setting from the TelemetryChatClient if available. this.EnableSensitiveData = this._openTelemetryChatClient?.EnableSensitiveData ?? false; @@ -112,21 +110,16 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable /// public override object? GetService(Type serviceType, object? serviceKey = null) - => base.GetService(serviceType, serviceKey) - ?? (serviceType == typeof(ActivitySource) ? this._activitySource - : this._innerAgent.GetService(serviceType, serviceKey)); + { + // Handle ActivitySource requests directly - always return our own ActivitySource + if (serviceType == typeof(ActivitySource)) + { + return this._activitySource; + } - /// - public override string Id => this._innerAgent.Id; - - /// - public override string? Name => this._innerAgent.Name; - - /// - public override string? Description => this._innerAgent.Description; - - /// - public override AgentThread GetNewThread() => this._innerAgent.GetNewThread(); + // For other service types, use the base delegation logic + return base.GetService(serviceType, serviceKey); + } /// public override async Task RunAsync( @@ -146,7 +139,7 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable Exception? error = null; try { - response = await this._innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); + response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false); return response; } catch (Exception ex) @@ -175,7 +168,7 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable IAsyncEnumerable updates; try { - updates = this._innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + updates = base.RunStreamingAsync(messages, thread, options, cancellationToken); } catch (Exception ex) { @@ -224,7 +217,7 @@ public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable private Activity? CreateAndConfigureActivity(string operationName, IReadOnlyCollection messages, AgentThread? thread) { // Get the GenAI system name for telemetry - var chatClientAgent = this._innerAgent as ChatClientAgent; + var chatClientAgent = this.InnerAgent as ChatClientAgent; Activity? activity = null; if (this._activitySource.HasListeners()) { diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs new file mode 100644 index 0000000000..22abf8e0ba --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegatingAIAgentTests +{ + private readonly Mock _innerAgentMock; + private readonly TestDelegatingAIAgent _delegatingAgent; + private readonly AgentRunResponse _testResponse; + private readonly List _testStreamingResponses; + private readonly AgentThread _testThread; + + /// + /// Initializes a new instance of the class. + /// + public DelegatingAIAgentTests() + { + this._innerAgentMock = new Mock(); + this._testResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); + this._testStreamingResponses = [new AgentRunResponseUpdate(ChatRole.Assistant, "Test streaming response")]; + this._testThread = new AgentThread(); + + // Setup inner agent mock + this._innerAgentMock.Setup(x => x.Id).Returns("test-agent-id"); + this._innerAgentMock.Setup(x => x.Name).Returns("Test Agent"); + this._innerAgentMock.Setup(x => x.Description).Returns("Test Description"); + this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread); + + this._innerAgentMock + .Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this._testResponse); + + this._innerAgentMock + .Setup(x => x.RunStreamingAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(this._testStreamingResponses)); + + this._delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerAgent is null. + /// + [Fact] + public void RequiresInnerAgent() + { + // Act & Assert + Assert.Throws("innerAgent", () => new TestDelegatingAIAgent(null!)); + } + + /// + /// Verify that constructor sets the inner agent correctly. + /// + [Fact] + public void Constructor_WithValidInnerAgent_SetsInnerAgent() + { + // Act + var delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object); + + // Assert + Assert.Same(this._innerAgentMock.Object, delegatingAgent.InnerAgent); + } + + #endregion + + #region Property Delegation Tests + + /// + /// Verify that Id property delegates to inner agent. + /// + [Fact] + public void Id_DelegatesToInnerAgent() + { + // Act + var id = this._delegatingAgent.Id; + + // Assert + Assert.Equal("test-agent-id", id); + this._innerAgentMock.Verify(x => x.Id, Times.Once); + } + + /// + /// Verify that Name property delegates to inner agent. + /// + [Fact] + public void Name_DelegatesToInnerAgent() + { + // Act + var name = this._delegatingAgent.Name; + + // Assert + Assert.Equal("Test Agent", name); + this._innerAgentMock.Verify(x => x.Name, Times.Once); + } + + /// + /// Verify that Description property delegates to inner agent. + /// + [Fact] + public void Description_DelegatesToInnerAgent() + { + // Act + var description = this._delegatingAgent.Description; + + // Assert + Assert.Equal("Test Description", description); + this._innerAgentMock.Verify(x => x.Description, Times.Once); + } + + #endregion + + #region Method Delegation Tests + + /// + /// Verify that GetNewThread delegates to inner agent. + /// + [Fact] + public void GetNewThread_DelegatesToInnerAgent() + { + // Act + var thread = this._delegatingAgent.GetNewThread(); + + // Assert + Assert.Same(this._testThread, thread); + this._innerAgentMock.Verify(x => x.GetNewThread(), Times.Once); + } + + /// + /// Verify that RunAsync delegates to inner agent with correct parameters. + /// + [Fact] + public async Task RunAsyncDefaultsToInnerAgentAsync() + { + // Arrange + var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") }; + var expectedThread = new AgentThread(); + var expectedOptions = new AgentRunOptions(); + var expectedCancellationToken = new CancellationToken(); + var expectedResult = new TaskCompletionSource(); + var expectedResponse = new AgentRunResponse(); + + var innerAgentMock = new Mock(); + innerAgentMock + .Setup(x => x.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken)) + .Returns(expectedResult.Task); + + var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); + + // Act + var resultTask = delegatingAgent.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedResponse); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedResponse, await resultTask); + } + + /// + /// Verify that RunStreamingAsync delegates to inner agent with correct parameters. + /// + [Fact] + public async Task RunStreamingAsyncDefaultsToInnerAgentAsync() + { + // Arrange + var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") }; + var expectedThread = new AgentThread(); + var expectedOptions = new AgentRunOptions(); + var expectedCancellationToken = new CancellationToken(); + AgentRunResponseUpdate[] expectedResults = + [ + new(ChatRole.Assistant, "Message 1"), + new(ChatRole.Assistant, "Message 2") + ]; + + var innerAgentMock = new Mock(); + innerAgentMock + .Setup(x => x.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken)) + .Returns(ToAsyncEnumerableAsync(expectedResults)); + + var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); + + // Act + var resultAsyncEnumerable = delegatingAgent.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken); + + // Assert + var enumerator = resultAsyncEnumerable.GetAsyncEnumerator(); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[0], enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[1], enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService throws ArgumentNullException when serviceType is null. + /// + [Fact] + public void GetServiceThrowsForNullType() + { + // Act & Assert + Assert.Throws("serviceType", () => this._delegatingAgent.GetService(null!)); + } + + /// + /// Verify that GetService returns the delegating agent itself when requesting compatible type and key is null. + /// + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Act + var agent = this._delegatingAgent.GetService(); + + // Assert + Assert.Same(this._delegatingAgent, agent); + } + + /// + /// Verify that GetService delegates to inner agent when service key is not null. + /// + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + var expectedResult = new Mock().Object; + var innerAgentMock = new Mock(); + innerAgentMock.Setup(x => x.GetService(typeof(AIAgent), expectedKey)).Returns(expectedResult); + var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); + + // Act + var agent = delegatingAgent.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, agent); + } + + /// + /// Verify that GetService delegates to inner agent when not compatible with request. + /// + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + var innerAgentMock = new Mock(); + innerAgentMock + .Setup(x => x.GetService(typeof(TimeZoneInfo), expectedKey)) + .Returns(expectedResult); + var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object); + + // Act + var tzi = delegatingAgent.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + #endregion + + #region Helper Methods + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) + { + await Task.Yield(); + foreach (var value in values) + { + yield return value; + } + } + + #endregion + + #region Test Implementation + + /// + /// Test implementation of DelegatingAIAgent for testing purposes. + /// + private sealed class TestDelegatingAIAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) + { + public new AIAgent InnerAgent => base.InnerAgent; + } + + #endregion +}