.NET: Use IA2AClientFactory to create A2AClient (#5277)

* Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample

- Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions
- Update A2ACardResolverExtensions to accept IA2AClientFactory
- Update A2AClientExtensions to accept IA2AClientFactory
- Update A2AAgent to use IA2AClientFactory for client creation
- Add A2AAgent_ProtocolSelection sample demonstrating protocol selection
- Add comprehensive unit tests for all changes
- Update README files with new sample reference

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

* Reorder params: options before loggerFactory in A2A extensions

Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent
and GetAIAgentAsync extension methods to follow the repo convention
of keeping LoggerFactory and CancellationToken as the last parameters.

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-04-15 17:29:36 +01:00
committed by GitHub
Unverified
parent 6ad9279f0f
commit 7417eeb7e6
14 changed files with 283 additions and 33 deletions
+1
View File
@@ -289,6 +289,7 @@
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when
// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings.
using A2A;
using Microsoft.Agents.AI;
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
// Initialize an A2ACardResolver to get an A2A agent card.
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
// Get the agent card
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding.
// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card
// advertises multiple supported interfaces.
A2AClientOptions options = new()
{
PreferredBindings = [ProtocolBindingNames.HttpJson]
};
// To prefer JSON-RPC instead, use:
// A2AClientOptions options = new()
// {
// PreferredBindings = [ProtocolBindingNames.JsonRpc]
// };
// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding.
AIAgent agent = agentCard.AsAIAgent(options: options);
// Invoke the agent and output the text result.
AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
Console.WriteLine(response);
@@ -0,0 +1,27 @@
# A2A Agent Protocol Selection
This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card.
A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used.
The sample:
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding
- Creates an `AIAgent` from the resolved agent card using the specified binding
- Sends a message to the agent and displays the response
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10.0 SDK or later
- An A2A agent server running and accessible via HTTP
**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md
Set the following environment variable:
```powershell
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
```
+2 -1
View File
@@ -3,7 +3,7 @@
These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework.
For other samples that demonstrate how to use AIAgent instances,
see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples.
see the [Getting Started With Agents](../Agents/README.md) samples.
## Prerequisites
@@ -16,6 +16,7 @@ See the README.md for each sample for the prerequisites for that sample.
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.|
## Running the samples from the console
+1
View File
@@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features |
@@ -27,7 +27,7 @@ public sealed class A2AAgent : AIAgent
{
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly A2AClient _a2aClient;
private readonly IA2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
@@ -41,7 +41,7 @@ public sealed class A2AAgent : AIAgent
/// <param name="name">The 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(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
@@ -224,7 +224,7 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(A2AClient) ? this._a2aClient
?? (serviceType == typeof(IA2AClient) ? this._a2aClient
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -26,16 +24,15 @@ public static class A2AAgentCardExtensions
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// 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, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
{
// TODO: Refactor to support interface selection from card.SupportedInterfaces.
var url = card.SupportedInterfaces?.FirstOrDefault()?.Url
?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces with a URL.");
// Create the A2A client using the agent URL from the card.
var a2aClient = new A2AClient(new Uri(url), httpClient);
var a2aClient = A2AClientFactory.Create(card, httpClient, options);
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
@@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// 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, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(httpClient, loggerFactory);
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
}
}
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace A2A;
/// <summary>
/// Provides extension methods for <see cref="A2AClient"/>
/// Provides extension methods for <see cref="IA2AClient"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
@@ -29,12 +29,12 @@ public static class A2AClientExtensions
/// <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="A2AClient" /> to use for the agent.</param>
/// <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="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 A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
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);
}
@@ -55,6 +55,21 @@ public sealed class A2AAgentTests : IDisposable
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!));
[Fact]
public void Constructor_WithIA2AClient_InitializesCorrectly()
{
// Arrange
IA2AClient ia2aClient = this._a2aClient;
// Act
var agent = new A2AAgent(ia2aClient, "ia2a-id", "IA2A Agent", "An agent from IA2AClient");
// Assert
Assert.Equal("ia2a-id", agent.Id);
Assert.Equal("IA2A Agent", agent.Name);
Assert.Equal("An agent from IA2AClient", agent.Description);
}
[Fact]
public void Constructor_WithDefaultParameters_UsesBaseProperties()
{
@@ -1371,17 +1386,31 @@ public sealed class A2AAgentTests : IDisposable
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns A2AClient when requested.
/// Verify that GetService returns IA2AClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingA2AClient_ReturnsA2AClient()
public void GetService_RequestingIA2AClient_ReturnsA2AClient()
{
// Arrange & Act
var result = this._agent.GetService(typeof(IA2AClient));
// Assert
Assert.NotNull(result);
Assert.Same(this._a2aClient, result);
}
/// <summary>
/// Verify that GetService returns null when requesting the concrete A2AClient type
/// since the agent now exposes IA2AClient instead.
/// </summary>
[Fact]
public void GetService_RequestingConcreteA2AClient_ReturnsNull()
{
// Arrange & Act
var result = this._agent.GetService(typeof(A2AClient));
// Assert
Assert.NotNull(result);
Assert.Same(this._a2aClient, result);
Assert.Null(result);
}
/// <summary>
@@ -1458,10 +1487,10 @@ public sealed class A2AAgentTests : IDisposable
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
/// </summary>
[Fact]
public void GetService_RequestingA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
public void GetService_RequestingIA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
{
// Arrange & Act - Request A2AClient with a service key (base.GetService will return null due to serviceKey)
var result = this._agent.GetService(typeof(A2AClient), "some-key");
// Arrange & Act - Request IA2AClient with a service key (base.GetService will return null due to serviceKey)
var result = this._agent.GetService(typeof(IA2AClient), "some-key");
// Assert
Assert.NotNull(result);
@@ -57,7 +57,7 @@ public sealed class A2AAgentCardExtensionsTests
Parts = [Part.FromText("Response")],
});
var agent = this._agentCard.AsAIAgent(httpClient);
var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
// Act
await agent.RunAsync("Test input");
@@ -68,7 +68,7 @@ public sealed class A2AAgentCardExtensionsTests
}
[Fact]
public async Task AsAIAgent_WithMultipleInterfaces_UsesFirstInterfaceAsync()
public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
{
// Arrange
var card = new AgentCard
@@ -77,9 +77,8 @@ public sealed class A2AAgentCardExtensionsTests
Description = "An agent with multiple interfaces",
SupportedInterfaces =
[
new AgentInterface { Url = "http://first/agent" },
new AgentInterface { Url = "http://second/agent", ProtocolBinding = "grpc" },
new AgentInterface { Url = "http://third/agent", ProtocolBinding = "http" },
new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
};
@@ -92,14 +91,79 @@ public sealed class A2AAgentCardExtensionsTests
Parts = [Part.FromText("Response")],
});
var agent = card.AsAIAgent(httpClient);
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
var agent = card.AsAIAgent(httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Single(handler.CapturedUris);
Assert.Equal(new Uri("http://first/agent"), handler.CapturedUris[0]);
Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
}
[Fact]
public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
{
// Arrange
var card = new AgentCard
{
Name = "Default Options Agent",
Description = "Tests default A2AClientOptions behavior",
SupportedInterfaces =
[
new AgentInterface { Url = "http://default/agent" },
]
};
// Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
var agent = card.AsAIAgent(options: null);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("Default Options Agent", agent.Name);
}
[Fact]
public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "Unmatched Binding Agent",
Description = "Agent with unsupported binding only",
SupportedInterfaces =
[
new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
]
};
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
// Act & Assert - factory should throw when no matching binding exists
Assert.ThrowsAny<Exception>(() => card.AsAIAgent(options: options));
}
[Fact]
public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "No Interfaces Agent",
Description = "Agent with no supported interfaces",
};
// Act & Assert
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
@@ -68,7 +68,7 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Parts = [Part.FromText("Response")],
});
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient);
// Act
await agent.RunAsync("Test input");
@@ -78,6 +78,41 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
}
[Fact]
public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Options Agent",
Description = "Agent with multiple interfaces",
SupportedInterfaces =
[
new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
});
this._handler.ResponsesToReturn.Enqueue(new Message
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
});
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Equal(2, this._handler.CapturedUris.Count);
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
}
public void Dispose()
{
this._handler.Dispose();
@@ -30,4 +30,40 @@ public sealed class A2AClientExtensionsTests
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
}
[Fact]
public void GetAIAgent_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"));
const string TestId = "ia2a-agent-id";
const string TestName = "IA2A Agent";
const string TestDescription = "Agent created from IA2AClient";
// Act
var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
}
[Fact]
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
{
// Arrange
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
// Act
var agent = a2aClient.AsAIAgent();
// Assert
var service = agent.GetService(typeof(IA2AClient));
Assert.NotNull(service);
Assert.Same(a2aClient, service);
}
}