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
+}