.NET: A2A agent (#520)

* add a2a agent

* Update dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs

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

* address pr review feedback

* move unit tests for extension methods to the extensions folder

* address pr review comments

* address pr review comments

* address pr review feedback

* move a2a agent sample to console app

* remove unnecessary Ids set for new projects in the solution file

* remove unnecessary configuration

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2025-09-01 09:30:30 +01:00
committed by GitHub
Unverified
parent 97d72c967f
commit 1d2f833122
30 changed files with 1656 additions and 1 deletions
@@ -0,0 +1,168 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Represents an <see cref="AIAgent"/> that can interact with remote agents that are exposed via the A2A protocol
/// </summary>
/// <remarks>
/// This agent supports only messages as a response from A2A agents.
/// Support for tasks will be added later as part of the long-running
/// executions work.
/// </remarks>
internal sealed class A2AAgent : AIAgent
{
private readonly A2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
private readonly string? _displayName;
private readonly ILogger _logger;
/// <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="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="displayName">The display name 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, string? displayName = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
this._a2aClient = a2aClient;
this._id = id;
this._name = name;
this._description = description;
this._displayName = displayName;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
ValidateInputMessages(messages);
var a2aMessage = messages.ToA2AMessage();
// Linking the message to the existing conversation, if any.
a2aMessage.ContextId = thread?.ConversationId;
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
var a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
if (a2aResponse is Message message)
{
UpdateThreadConversationId(thread, message);
return new AgentRunResponse
{
AgentId = this.Id,
ResponseId = message.MessageId,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
};
}
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ValidateInputMessages(messages);
var a2aMessage = messages.ToA2AMessage();
// Linking the message to the existing conversation, if any.
a2aMessage.ContextId = thread?.ConversationId;
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
var a2aSseEvents = this._a2aClient.SendMessageStreamAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
await foreach (var sseEvent in a2aSseEvents)
{
if (sseEvent.Data is not Message message)
{
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}");
}
UpdateThreadConversationId(thread, message);
yield return new AgentRunResponseUpdate
{
AgentId = this.Id,
ResponseId = message.MessageId,
RawRepresentation = message,
Role = ChatRole.Assistant,
MessageId = message.MessageId,
Contents = [.. message.Parts.Select(part => part.ToAIContent())],
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
};
}
}
/// <inheritdoc/>
public override string Id => this._id ?? base.Id;
/// <inheritdoc/>
public override string? Name => this._name ?? base.Name;
/// <inheritdoc/>
public override string DisplayName => this._displayName ?? base.DisplayName;
/// <inheritdoc/>
public override string? Description => this._description ?? base.Description;
private static void ValidateInputMessages(IReadOnlyCollection<ChatMessage> messages)
{
_ = Throw.IfNull(messages);
foreach (var message in messages)
{
if (message.Role != ChatRole.User)
{
throw new ArgumentException($"All input messages for A2A agents must have the role '{ChatRole.User}'. Found '{message.Role}'.", nameof(messages));
}
}
}
private static void UpdateThreadConversationId(AgentThread? thread, Message message)
{
if (thread is null)
{
return;
}
// Surface cases where the A2A agent responds with a message that
// has a different context Id than the thread's conversation Id.
if (thread.ConversationId is not null && message.ContextId is not null && thread.ConversationId != message.ContextId)
{
throw new InvalidOperationException(
$"The {nameof(message.ContextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
}
// Assign a server-generated context Id to the thread if it's not already set.
thread.ConversationId ??= message.ContextId;
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extensions for logging <see cref="A2AAgent"/> invocations.
/// </summary>
[ExcludeFromCodeCoverage]
internal static partial class A2AAgentLogMessages
{
/// <summary>
/// Logs <see cref="A2AAgent"/> invoking agent (started).
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoking underlying A2A agent.")]
public static partial void LogA2AAgentInvokingAgent(
this ILogger logger,
string methodName,
string agentId,
string? agentName);
/// <summary>
/// Logs <see cref="A2AAgent"/> invoked agent (complete).
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoked underlying A2A agent.")]
public static partial void LogAgentChatClientInvokedAgent(
this ILogger logger,
string methodName,
string agentId,
string? agentName);
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Provides extension methods for <see cref="A2ACardResolver"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
/// These extensions bridge the gap between A2A SDK client objects
/// and the Microsoft Extensions AI Agent framework.
/// <para>
/// They allow developers to easily create AI agents that can interact
/// with A2A agents by handling the conversion from A2A clients to
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
/// </para>
/// </remarks>
public static class A2ACardResolverExtensions
{
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to create AI agents for A2A agents whose hosts support one of the A2A discovery mechanisms:
/// <list type="bullet">
/// <item><see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see></item>
/// <item><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></item>
/// </list>
/// </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="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to use when retrieving the agent card.</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)
{
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
// Create the A2A client using the agent URL from the card.
var a2aClient = new A2AClient(new Uri(agentCard.Url), httpClient);
return a2aClient.GetAIAgent(name: agentCard.Name, description: agentCard.Description, loggerFactory: loggerFactory);
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Provides extension methods for <see cref="A2AClient"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
/// These extensions bridge the gap between A2A SDK client objects
/// and the Microsoft Extensions AI Agent framework.
/// <para>
/// They allow developers to easily create AI agents that can interact
/// with A2A agents by handling the conversion from A2A clients to
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
/// </para>
/// </remarks>
public static class A2AClientExtensions
{
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to create AI agents for A2A agents whose hosts 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="A2AClient" /> 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="displayName">The display name 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 GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null)
{
return new A2AAgent(client, id, name, description, displayName, loggerFactory);
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="Message"/> class.
/// </summary>
internal static class A2AMessageExtensions
{
public static ChatMessage ToChatMessage(this Message message)
{
List<AIContent>? aiContents = null;
foreach (var part in message.Parts)
{
(aiContents ??= []).Add(part.ToAIContent());
}
return new ChatMessage(ChatRole.Assistant, aiContents)
{
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
RawRepresentation = message,
};
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for A2A metadata dictionary.
/// </summary>
internal static class A2AMetadataExtensions
{
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
public static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (var kvp in metadata)
{
additionalProperties[kvp.Key] = kvp.Value;
}
return additionalProperties;
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="Part"/> class.
/// </summary>
internal static class A2APartExtensions
{
/// <summary>
/// Converts an A2A <see cref="Part"/> to an <see cref="AIContent"/>.
/// </summary>
/// <param name="part">The A2A part to convert.</param>
/// <returns>The corresponding <see cref="AIContent"/>.</returns>
public static AIContent ToAIContent(this Part part)
{
return part switch
{
TextPart textPart => new TextContent(textPart.Text)
{
RawRepresentation = textPart,
AdditionalProperties = textPart.Metadata.ToAdditionalProperties()
},
FilePart filePart when filePart.File is FileWithUri fileWithUrl => new HostedFileContent(fileWithUrl.Uri)
{
RawRepresentation = filePart,
AdditionalProperties = filePart.Metadata.ToAdditionalProperties()
},
_ => throw new NotSupportedException($"Part type '{part.GetType().Name}' is not supported.")
};
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="AIContent"/> class.
/// </summary>
internal static class AIContentExtensions
{
/// <summary>
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
/// </summary>
/// <param name="contents">The collection of AI contents to convert.</param>"
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
public static List<Part>? ToA2AParts(this IEnumerable<AIContent> contents)
{
List<Part>? parts = null;
foreach (var content in contents)
{
(parts ??= []).Add(content.ToA2APart());
}
return parts;
}
/// <summary>
/// Converts a <see cref="AIContent"/> to a <see cref="Part"/> object."/>
/// </summary>
/// <param name="content">AI content to convert.</param>
/// <returns>The corresponding A2A <see cref="Part"/> object.</returns>
public static Part ToA2APart(this AIContent content)
{
return content switch
{
TextContent textContent => new TextPart { Text = textContent.Text },
HostedFileContent hostedFileContent => new FilePart { File = new FileWithUri { Uri = hostedFileContent.FileId } },
_ => throw new NotSupportedException($"Unsupported content type: {content.GetType().Name}."),
};
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="ChatMessage"/> class.
/// </summary>
internal static class ChatMessageExtensions
{
public static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
{
List<Part> allParts = [];
foreach (var message in messages)
{
if (message.Contents.ToA2AParts() is { Count: > 0 } ps)
{
allParts.AddRange(ps);
}
}
return new Message
{
MessageId = Guid.NewGuid().ToString(),
Role = MessageRole.User,
Parts = allParts,
};
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft.Extensions.AI.Agents.A2A</Title>
<Description>Defines AIAgent for interacting with application-to-application (A2A) agents.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.A2A.UnitTests" />
</ItemGroup>
</Project>