.NET: Add an A2A client server sample (#633)

* Add an A2A client server sample

* Address code review feedback

* Start to fix code review feedback

* Start to fix code review feedback

* Start to fix code review feedback
This commit is contained in:
Mark Wallace
2025-09-09 17:03:10 +00:00
committed by GitHub
parent ec5ea3c8a8
commit 745a05d43c
29 changed files with 1550 additions and 14 deletions
@@ -69,7 +69,7 @@ internal sealed class A2AAgent : AIAgent
if (a2aResponse is Message message)
{
UpdateThreadConversationId(thread, message);
UpdateThreadConversationId(thread, message.ContextId);
return new AgentRunResponse
{
@@ -80,8 +80,21 @@ internal sealed class A2AAgent : AIAgent
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
};
}
if (a2aResponse is AgentTask agentTask)
{
UpdateThreadConversationId(thread, agentTask.ContextId);
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
return new AgentRunResponse
{
AgentId = this.Id,
ResponseId = agentTask.Id,
RawRepresentation = agentTask,
Messages = agentTask.ToChatMessages(),
AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(),
};
}
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
}
/// <inheritdoc/>
@@ -107,7 +120,7 @@ internal sealed class A2AAgent : AIAgent
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}");
}
UpdateThreadConversationId(thread, message);
UpdateThreadConversationId(thread, message.ContextId);
yield return new AgentRunResponseUpdate
{
@@ -147,22 +160,22 @@ internal sealed class A2AAgent : AIAgent
}
}
private static void UpdateThreadConversationId(AgentThread? thread, Message message)
private static void UpdateThreadConversationId(AgentThread? thread, string? contextId)
{
if (thread is null)
{
return;
}
// Surface cases where the A2A agent responds with a message that
// Surface cases where the A2A agent responds with a response 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)
if (thread.ConversationId is not null && contextId is not null && thread.ConversationId != contextId)
{
throw new InvalidOperationException(
$"The {nameof(message.ContextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
$"The {nameof(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;
thread.ConversationId ??= contextId;
}
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Host which will attach an <see cref="AIAgent"/> to a <see cref="ITaskManager"/>
/// </summary>
/// <remarks>
/// This implementation only handles:
/// <list type="bullet">
/// <item><code>TaskManager.OnMessageReceived</code></item>
/// <item><code>TaskManager.OnAgentCardQuery</code></item>
/// </list>
/// Support for task management will be added later as part of the long-running task execution work.
/// </remarks>
public sealed class A2AHostAgent
{
/// <summary>
/// Initializes a new instance of the SemanticKernelTravelAgent
/// </summary>
public A2AHostAgent(AIAgent agent, AgentCard agentCard, TaskManager? taskManager = null)
{
Throw.IfNull(agent);
Throw.IfNull(agentCard);
this.Agent = agent;
this._agentCard = agentCard;
this.Attach(taskManager ?? new TaskManager());
}
/// <summary>
/// The associated <see cref="AIAgent"/>
/// </summary>
public AIAgent? Agent { get; private set; }
/// <summary>
/// The associated <see cref="ITaskManager"/>
/// </summary>
public TaskManager? TaskManager => this._taskManager;
/// <summary>
/// Attach the <see cref="A2AAgent"/> to the provided <see cref="ITaskManager"/>
/// </summary>
/// <param name="taskManager"></param>
public void Attach(TaskManager taskManager)
{
Throw.IfNull(taskManager);
this._taskManager = taskManager;
taskManager.OnMessageReceived = this.OnMessageReceivedAsync;
taskManager.OnAgentCardQuery = this.GetAgentCardAsync;
}
/// <summary>
/// Handle a received message
/// </summary>
/// <param name="messageSend">The <see cref="MessageSendParams"/> to handle</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the operation</param>
public async Task<Message> OnMessageReceivedAsync(MessageSendParams messageSend, CancellationToken cancellationToken = default)
{
Throw.IfNull(messageSend);
Throw.IfNull(this.Agent);
if (this._taskManager is null)
{
throw new InvalidOperationException("TaskManager must be attached before handling an agent message.");
}
// Get message from the user
var userMessage = messageSend.Message.ToChatMessage();
// Get the response from the agent
var message = new Message();
var agentResponse = await this.Agent.RunAsync(userMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var chatMessage in agentResponse.Messages)
{
var content = chatMessage.Text;
message.Parts.Add(new TextPart() { Text = content! });
}
return message;
}
/// <summary>
/// Return the <see cref="AgentCard"/> associated with this hosted agent.
/// </summary>
/// <param name="agentUrl">Current URL for the agent</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the operation</param>
#pragma warning disable CA1054 // URI-like parameters should not be strings
public Task<AgentCard> GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
{
// Ensure the URL is in the correct format
Uri uri = new(agentUrl);
agentUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
this._agentCard.Url = agentUrl;
return Task.FromResult(this._agentCard);
}
#pragma warning restore CA1054 // URI-like parameters should not be strings
#region private
private readonly AgentCard _agentCard;
private TaskManager? _taskManager;
#endregion
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="AgentTask"/> class.
/// </summary>
internal static class A2AAgentTaskExtensions
{
internal static IList<ChatMessage> ToChatMessages(this AgentTask agentTask)
{
_ = Throw.IfNull(agentTask);
List<ChatMessage> messages = [];
if (agentTask.Artifacts is not null)
{
foreach (var artifact in agentTask.Artifacts)
{
messages.Add(artifact.ToChatMessage());
}
}
return messages;
}
}
@@ -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="Artifact"/> class.
/// </summary>
internal static class A2AArtifactExtensions
{
internal static ChatMessage ToChatMessage(this Artifact artifact)
{
List<AIContent>? aiContents = null;
foreach (var part in artifact.Parts)
{
(aiContents ??= []).Add(part.ToAIContent());
}
return new ChatMessage(ChatRole.Assistant, aiContents)
{
AdditionalProperties = artifact.Metadata.ToAdditionalProperties(),
RawRepresentation = artifact,
};
}
}
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.A2A;
/// </summary>
internal static class A2AMessageExtensions
{
public static ChatMessage ToChatMessage(this Message message)
internal static ChatMessage ToChatMessage(this Message message)
{
List<AIContent>? aiContents = null;
@@ -15,7 +15,7 @@ internal static class A2AMetadataExtensions
/// </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)
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
@@ -15,7 +15,7 @@ internal static class A2APartExtensions
/// </summary>
/// <param name="part">The A2A part to convert.</param>
/// <returns>The corresponding <see cref="AIContent"/>.</returns>
public static AIContent ToAIContent(this Part part)
internal static AIContent ToAIContent(this Part part)
{
return part switch
{
@@ -16,7 +16,7 @@ internal static class AIContentExtensions
/// </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)
internal static List<Part>? ToA2AParts(this IEnumerable<AIContent> contents)
{
List<Part>? parts = null;
@@ -33,7 +33,7 @@ internal static class AIContentExtensions
/// </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)
internal static Part ToA2APart(this AIContent content)
{
return content switch
{
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.A2A;
/// </summary>
internal static class ChatMessageExtensions
{
public static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
internal static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
{
List<Part> allParts = [];
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using static Microsoft.Extensions.AI.Agents.OpenTelemetryConsts.GenAI;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Provides factory methods for creating implementations of <see cref="AIFunction"/> backed by an <see cref="AIAgent" />.
/// </summary>
public static class AgentAIFunctionFactory
{
/// <summary>
/// Creates a <see cref="AIFunction"/> that will invoke the provided Agent.
/// </summary>
/// <param name="agent">The <see cref="Agent" /> to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="agent"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking the <see cref="AIAgent"/>.</returns>
public static AIFunction CreateFromAgent(
AIAgent agent,
AIFunctionFactoryOptions? options = null)
{
Throw.IfNull(agent);
async Task<string> RunAgentAsync(string query, CancellationToken cancellationToken)
{
var response = await agent.RunAsync(query, cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Text;
}
return AIFunctionFactory.Create(RunAgentAsync, options ?? new()
{
Name = agent.Name,
Description = agent.Description,
});
}
}