.NET: Add A2AAgentOptions and align A2AAgent constructors with ChatClientAgent pattern (#5954)

* .NET: Add A2AAgentOptions and align A2AAgent constructors with ChatClientAgent pattern

Adds a new A2AAgentOptions class (Id, Name, Description, Clone) and an options-based constructor on A2AAgent, mirroring ChatClientAgent/ChatClientAgentOptions. The existing parameter-based constructor is preserved for backward compatibility and now delegates to the options-based one.

Extension methods are extended with options-based overloads:

- A2AClientExtensions.AsAIAgent(IA2AClient, A2AAgentOptions, ...)

- A2AAgentCardExtensions.AsAIAgent(AgentCard, A2AAgentOptions, ...)

- A2ACardResolverExtensions.GetAIAgentAsync(A2ACardResolver, A2AAgentOptions, ...)

For card-based creation, user-supplied options override values from the agent card; Name and Description fall back to card values when not set.

Options are cloned when stored on the agent to prevent post-construction mutation, matching the ChatClientAgent pattern.

Resolves #5870.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review comments

- Add Throw.IfNull(client) in A2AClientExtensions.AsAIAgent

- Add Throw.IfNull(card) in A2AAgentCardExtensions.AsAIAgent

- Clarify httpClient docs in A2ACardResolverExtensions.GetAIAgentAsync: it applies to the created A2A client, not to card discovery

- Rename test methods from GetAIAgent_* to AsAIAgent_* to match the API under test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-05-20 11:05:24 +01:00
committed by GitHub
Unverified
parent f390595188
commit dd1e615dad
9 changed files with 406 additions and 15 deletions
+28 -10
View File
@@ -28,9 +28,7 @@ public sealed class A2AAgent : AIAgent
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly IA2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
private readonly A2AAgentOptions _agentOptions;
private readonly ILogger _logger;
/// <summary>
@@ -38,17 +36,37 @@ public sealed class A2AAgent : AIAgent
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
: this(
a2aClient,
new A2AAgentOptions
{
Id = id,
Name = name,
Description = description
},
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
_ = Throw.IfNull(options);
this._a2aClient = a2aClient;
this._id = id;
this._name = name;
this._description = description;
this._agentOptions = options.Clone();
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
@@ -216,13 +234,13 @@ public sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
protected override string? IdCore => this._id;
protected override string? IdCore => this._agentOptions.Id;
/// <inheritdoc/>
public override string? Name => this._name;
public override string? Name => this._agentOptions.Name;
/// <inheritdoc/>
public override string? Description => this._description;
public override string? Description => this._agentOptions.Description;
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Represents configuration options for an <see cref="A2AAgent"/>, including its identifier, name, and description.
/// </summary>
/// <remarks>
/// This class is used to encapsulate information about an A2A agent, such as its unique
/// identifier, display name, and a descriptive summary. It provides an alternative to passing
/// these values as individual constructor parameters.
/// </remarks>
public sealed class A2AAgentOptions
{
/// <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 description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Creates a new instance of <see cref="A2AAgentOptions"/> with the same values as this instance.
/// </summary>
public A2AAgentOptions Clone()
=> new()
{
Id = this.Id,
Name = this.Name,
Description = this.Description
};
}
@@ -2,7 +2,9 @@
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -36,4 +38,39 @@ public static class A2AAgentCardExtensions
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the <see cref="AgentCard"/>.
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the agent card.
/// </param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this AgentCard card, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(card);
_ = Throw.IfNull(agentOptions);
var a2aClient = A2AClientFactory.Create(card, httpClient, clientOptions);
var mergedOptions = agentOptions.Clone();
mergedOptions.Name ??= card.Name;
mergedOptions.Description ??= card.Description;
return a2aClient.AsAIAgent(mergedOptions, loggerFactory);
}
}
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -48,4 +49,39 @@ public static class A2ACardResolverExtensions
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the resolved <see cref="AgentCard"/>.
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the resolved agent card.
/// </param>
/// <param name="httpClient">
/// The <see cref="HttpClient"/> to use for HTTP requests made by the created A2A client.
/// This is not used for fetching the agent card; the resolver uses its own configured client for that.
/// </param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agentOptions);
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(agentOptions, httpClient, clientOptions, loggerFactory);
}
}
@@ -3,6 +3,7 @@
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -31,10 +32,32 @@ public static class A2AClientExtensions
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
/// discovery mechanism.
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(client);
_ = Throw.IfNull(options);
return new A2AAgent(client, options, loggerFactory);
}
}
@@ -83,6 +83,72 @@ public sealed class A2AAgentTests : IDisposable
Assert.Null(agent.Description);
}
[Fact]
public void Constructor_WithOptions_InitializesPropertiesCorrectly()
{
// Arrange
var options = new A2AAgentOptions
{
Id = "options-id",
Name = "options-name",
Description = "options-description"
};
// Act
var agent = new A2AAgent(this._a2aClient, options);
// Assert
Assert.Equal("options-id", agent.Id);
Assert.Equal("options-name", agent.Name);
Assert.Equal("options-description", agent.Description);
}
[Fact]
public void Constructor_WithOptions_IsolatesAgentFromOptionsMutation()
{
// Arrange
var options = new A2AAgentOptions
{
Id = "original-id",
Name = "Original Name",
Description = "Original Description"
};
var agent = new A2AAgent(this._a2aClient, options);
// Act - mutate options after agent construction
options.Id = "mutated-id";
options.Name = "Mutated Name";
options.Description = "Mutated Description";
// Assert - agent should retain original values
Assert.Equal("original-id", agent.Id);
Assert.Equal("Original Name", agent.Name);
Assert.Equal("Original Description", agent.Description);
}
[Fact]
public void Constructor_WithNullOptions_ThrowsArgumentNullException() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(this._a2aClient, options: null!));
[Fact]
public void Constructor_WithEmptyOptions_UsesBaseProperties()
{
// Act
var agent = new A2AAgent(this._a2aClient, new A2AAgentOptions());
// Assert
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
}
[Fact]
public void Constructor_WithOptions_NullA2AClient_ThrowsArgumentNullException() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!, new A2AAgentOptions()));
[Fact]
public async Task RunAsync_AllowsNonUserRoleMessagesAsync()
{
@@ -31,7 +31,7 @@ public sealed class A2AAgentCardExtensionsTests
}
[Fact]
public void GetAIAgent_ReturnsAIAgent()
public void AsAIAgent_ReturnsAIAgent()
{
// Act
var agent = this._agentCard.AsAIAgent();
@@ -165,6 +165,81 @@ public sealed class A2AAgentCardExtensionsTests
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
}
[Fact]
public void AsAIAgent_WithAgentOptions_OverridesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id",
Name = "Custom Agent",
Description = "Custom description"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Custom Agent", agent.Name);
Assert.Equal("Custom description", agent.Description);
}
[Fact]
public void AsAIAgent_WithAgentOptions_FallsBackToCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
[Fact]
public void AsAIAgent_WithEmptyAgentOptions_UsesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
// Act
var agent = card.AsAIAgent(new A2AAgentOptions());
// Assert
Assert.NotNull(agent);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
@@ -113,6 +113,61 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
}
[Fact]
public async Task GetAIAgentAsync_WithAgentOptions_OverridesCardValuesAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
var agentOptions = new A2AAgentOptions
{
Id = "custom-id",
Name = "Custom Agent",
Description = "Custom description"
};
// Act
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Custom Agent", agent.Name);
Assert.Equal("Custom description", agent.Description);
}
[Fact]
public async Task GetAIAgentAsync_WithAgentOptions_FallsBackToCardValuesAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
var agentOptions = new A2AAgentOptions
{
Id = "custom-id"
};
// Act
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
public void Dispose()
{
this._handler.Dispose();
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.A2A.UnitTests;
public sealed class A2AClientExtensionsTests
{
[Fact]
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
public void AsAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -32,7 +32,7 @@ public sealed class A2AClientExtensionsTests
}
[Fact]
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
public void AsAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -53,7 +53,7 @@ public sealed class A2AClientExtensionsTests
}
[Fact]
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
public void AsAIAgent_WithIA2AClient_ExposesClientViaGetService()
{
// Arrange
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -66,4 +66,45 @@ public sealed class A2AClientExtensionsTests
Assert.NotNull(service);
Assert.Same(a2aClient, service);
}
[Fact]
public void AsAIAgent_WithOptions_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
var options = new A2AAgentOptions
{
Id = "options-agent-id",
Name = "Options Agent",
Description = "Agent created with options"
};
// Act
var agent = a2aClient.AsAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("options-agent-id", agent.Id);
Assert.Equal("Options Agent", agent.Name);
Assert.Equal("Agent created with options", agent.Description);
}
[Fact]
public void AsAIAgent_WithEmptyOptions_ReturnsA2AAgentWithDefaultProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
// Act
var agent = a2aClient.AsAIAgent(new A2AAgentOptions());
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
}
}