diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 1600c872de..7e9b2b4444 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -11,6 +11,7 @@ disable $(NoWarn);IDE0290;IDE0079;NU5128 true + net9.0 net9.0;net8.0;netstandard2.0;net472 net9.0;net472 true diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a90c7abc6f..2e705607f7 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -70,6 +70,8 @@ + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 033eceebb0..40dcd00c01 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -123,6 +123,8 @@ + + @@ -144,6 +146,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index bfb23fa3ff..24b9d93995 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -14,6 +14,7 @@ + @@ -29,7 +30,13 @@ - + + + + + + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 722a1ba87a..72311b4ee4 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Azure.Cosmos; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Extensions.AI.Agents.Hosting; +using Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore; using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB; var builder = WebApplication.CreateBuilder(args); @@ -92,6 +93,18 @@ app.UseExceptionHandler(); app.MapActors(); +// attach a2a with simple message communication +app.AttachA2A(agentName: "pirate", path: "/a2a/pirate"); +app.AttachA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", agentCard: new() +{ + Name = "Knights and Knaves", + Description = "An agent that helps you solve the knights and knaves puzzle.", + Version = "1.0", + + // Url can be not set, and SDK will help assign it. + // Url = "http://localhost:5390/a2a/knights-and-knaves" +}); + // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AActorClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AActorClient.cs new file mode 100644 index 0000000000..b5be434617 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AActorClient.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents.Hosting; +using Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace AgentWebChat.Web; + +internal sealed class A2AActorClient : IActorClient +{ + private readonly ILogger _logger; + private readonly Uri _uri; + + // because A2A sdk does not provide a client which can handle multiple agents, we need a client per agent + // for this app the convention is "baseUri/" + private readonly ConcurrentDictionary _clients = new(); + + public A2AActorClient(ILogger logger, Uri baseUri) + { + this._logger = logger; + this._uri = baseUri; + } + + public Task GetAgentCardAsync(string agent, CancellationToken cancellationToken = default) + { + this._logger.LogInformation("Retrieving agent card for {Agent}", agent); + + var (_, a2aCardResolver) = this.ResolveClient(agent); + return a2aCardResolver.GetAgentCardAsync(cancellationToken); + } + + public ValueTask GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask SendRequestAsync(ActorRequest request, CancellationToken cancellationToken) + { + var agentName = request.ActorId.Type; + var (a2aClient, _) = this.ResolveClient(agentName); + + return new ValueTask(new A2AActorResponseHandle(a2aClient, request)); + } + + private (A2AClient, A2ACardResolver) ResolveClient(ActorType agentName) + => this.ResolveClient(agentName.Name); + + private (A2AClient, A2ACardResolver) ResolveClient(string agentName) + { + return this._clients.GetOrAdd(agentName, name => + { + var uri = new Uri($"{this._uri}/{name}/"); + var a2aClient = new A2AClient(uri); + + // /v1/card is a default path for A2A agent card discovery + var a2aCardResolver = new A2ACardResolver(uri, agentCardPath: "/v1/card/"); + + this._logger.LogInformation("Built clients for agent {Agent} with baseUri {Uri}", name, uri); + return (a2aClient, a2aCardResolver); + }); + } + + private sealed class A2AActorResponseHandle : ActorResponseHandle + { + private readonly A2AClient _a2aClient; + private readonly ActorRequest _request; + + public A2AActorResponseHandle(A2AClient a2aClient, ActorRequest request) + { + this._a2aClient = a2aClient; + this._request = request; + } + + public override ValueTask CancelAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public override ValueTask GetResponseAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response) + { + throw new NotImplementedException(); + } + + public override async IAsyncEnumerable WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var agentRunRequestData = this._request.Params.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))) as AgentRunRequest; + var messageTexts = agentRunRequestData!.Messages!.SelectMany(x => x.Contents.OfType()).Select(x => x.Text); + var parts = messageTexts.Select(text => new TextPart { Text = text }); + var messageSendParams = new MessageSendParams + { + Message = new() + { + Role = MessageRole.User, + MessageId = this._request.MessageId, + ContextId = this._request.ActorId.Key, + Parts = [.. parts] + } + }; + + await foreach (var upd in this._a2aClient.SendMessageStreamAsync(messageSendParams, cancellationToken)) + { + var @event = upd.Data; + if (@event is not Message message) + { + throw new NotSupportedException("Only message is supported in A2A processing, but got: " + @event.GetType()); + } + + // handling of message on agentProxy side expects the + yield return message.ToActorRequestUpdate(status: RequestStatus.Pending); + } + + // complete request after all updates are sent + yield return new ActorRequestUpdate(status: RequestStatus.Completed, data: default); + } + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index ed929f6d80..2c2528c870 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -5,15 +5,19 @@ enable enable - - - - - + + + + + + + + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index face3c14b6..9e15c145e3 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -4,6 +4,7 @@ @inject IJSRuntime JSRuntime @inject ILogger Logger @inject IActorClient ActorClient +@inject A2AActorClient A2AActorClient @rendermode InteractiveServer @using System.Text @using System.Text.Json @@ -11,6 +12,7 @@ @using Microsoft.Extensions.AI.Agents @using Microsoft.Extensions.AI.Agents.Hosting @using Microsoft.Extensions.AI.Agents.Runtime +@using A2A Agent Web Chat @@ -48,7 +50,105 @@ - @if (conversations.Any()) +
+ +
+ +
+ @switch (selectedProtocol) + { + case Protocol.A2A: + 🔗 A2A protocol supports long-running agentic processes + break; + case Protocol.AgenticFramework: + default: + ⚡ Direct agentic framework communication + break; + } +
+
+
+ + @if (selectedProtocol == Protocol.A2A) + { +
+
+

+ + + + A2A Configuration +

+ Discover and configure agent cards +
+ + @if (isA2AExpanded) + { +
+
+ + + @if (!string.IsNullOrEmpty(selectedAgentName)) + { + for agent: @GetAgentDisplayName(selectedAgentName) + } + else + { + Please select an agent first + } +
+ + @if (discoveredAgentCardJson != null) + { +
+

🔗 Discovered Agent Card

+
+
+
+ Agent Card JSON: +
+
@discoveredAgentCardJson
+
+
+
+ } + + @if (!string.IsNullOrEmpty(discoveryError)) + { +
+ + + + + + @discoveryError +
+ } +
+ } +
+ } + + @if (conversations.Any()) {
@@ -175,7 +275,7 @@ margin: 0; } - .agent-selection-card { + .agent-selection-card, .protocol-selection-card, .a2a-configuration-card { background: white; border-radius: 12px; padding: 1.5rem; @@ -183,20 +283,20 @@ margin-bottom: 2rem; } - .agent-select-label { + .agent-select-label, .protocol-select-label { display: block; font-weight: 600; color: #374151; margin-bottom: 0.75rem; } - .agent-select-wrapper { + .agent-select-wrapper, .protocol-select-wrapper { display: flex; gap: 1rem; align-items: center; } - .agent-select { + .agent-select, .protocol-select { flex: 1; padding: 0.75rem 1rem; font-size: 1rem; @@ -208,21 +308,281 @@ transition: all 0.2s; } - .agent-select:hover:not(:disabled) { + .agent-select:hover:not(:disabled), .protocol-select:hover:not(:disabled) { border-color: #6366f1; } - .agent-select:focus { + .agent-select:focus, .protocol-select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); } - .agent-select:disabled { + .agent-select:disabled, .protocol-select:disabled { opacity: 0.5; cursor: not-allowed; } + .protocol-info { + flex: 1; + min-width: 200px; + } + + .protocol-description { + font-size: 0.875rem; + color: #6b7280; + font-style: italic; + } + + /* A2A Configuration Card Styles */ + .a2a-header { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.5rem 0; + transition: all 0.2s; + } + + .a2a-header:hover { + background: rgba(99, 102, 241, 0.05); + border-radius: 8px; + padding: 0.5rem; + margin: -0.5rem; + } + + .a2a-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #374151; + } + + .a2a-toggle-icon { + transition: transform 0.2s; + } + + .a2a-toggle-icon.expanded { + transform: rotate(180deg); + } + + .a2a-subtitle { + font-size: 0.875rem; + color: #6b7280; + margin-left: 1.25rem; + } + + .a2a-content { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; + } + + .discover-section { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .discover-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: #059669; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .discover-btn:hover:not(:disabled) { + background: #047857; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3); + } + + .discover-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + + .discover-info { + font-size: 0.875rem; + color: #6b7280; + } + + .text-muted { + color: #9ca3af !important; + } + + .spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + /* Agent Card Display */ + .agent-card-display { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; + } + + .card-title { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: #374151; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .card-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + /* JSON Display Styles */ + .json-container { + background: #1e293b; + border-radius: 8px; + overflow: hidden; + border: 1px solid #334155; + } + + .json-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: #334155; + border-bottom: 1px solid #475569; + } + + .json-label { + font-size: 0.875rem; + font-weight: 600; + color: #e2e8f0; + } + + .copy-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: #475569; + color: #e2e8f0; + border: none; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .copy-btn:hover { + background: #64748b; + color: white; + } + + .json-display { + margin: 0; + padding: 1rem; + background: #1e293b; + color: #e2e8f0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + } + + .json-display code { + background: none; + color: inherit; + font-family: inherit; + padding: 0; + } + + /* JSON Syntax Highlighting */ + .json-display { + /* JSON strings */ + --json-string: #a3d977; + /* JSON numbers */ + --json-number: #ffc777; + /* JSON booleans */ + --json-boolean: #ff966c; + /* JSON null */ + --json-null: #c53030; + /* JSON keys */ + --json-key: #82aaff; + /* JSON punctuation */ + --json-punctuation: #c792ea; + } + + .card-property { + display: flex; + gap: 0.75rem; + align-items: flex-start; + } + + .card-property label { + font-weight: 600; + color: #374151; + min-width: 100px; + flex-shrink: 0; + } + + .card-property span { + color: #6b7280; + flex: 1; + } + + .capabilities-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .capability-tag { + background: #dbeafe; + color: #1e40af; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + /* Error Display */ + .error-display { + background: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 0.75rem; + border-radius: 8px; + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + } + .start-chat-btn { display: flex; align-items: center; @@ -494,190 +854,287 @@ .chat-container { height: 500px; } + + .protocol-select-wrapper { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .protocol-info { + min-width: auto; + } + + .discover-section { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .card-property { + flex-direction: column; + gap: 0.25rem; + } + + .card-property label { + min-width: auto; + } } @code { + private string currentMessage = ""; + private bool isStreaming = false; + private bool isLoadingAgents = true; + private string currentStreamedMessage = ""; + private string selectedAgentName = ""; + private List availableAgents = new(); + private List conversations = new(); + private Conversation? currentConversation; + // protocol + private Protocol selectedProtocol; + // a2a agent card + private bool isA2AExpanded = false; + private bool isDiscoveringCard = false; + private string? discoveredAgentCardJson = null; + private string? discoveryError = null; - private string currentMessage = ""; - private bool isStreaming = false; - private bool isLoadingAgents = true; - private string currentStreamedMessage = ""; - private string selectedAgentName = ""; - private List availableAgents = new(); - private List conversations = new(); - private Conversation? currentConversation; + private enum Protocol + { + AgenticFramework, + A2A // Agent-to-Agent protocol + } - private class Conversation - { - public string SessionId { get; set; } = Guid.NewGuid().ToString(); - public string AgentName { get; set; } = ""; - public List Messages { get; set; } = new(); - } + private class Conversation + { + public string SessionId { get; set; } = Guid.NewGuid().ToString(); + public string AgentName { get; set; } = ""; + public List Messages { get; set; } = new(); + } - protected override async Task OnInitializedAsync() - { - Logger.LogDebug("Initializing Agent Chat component"); + protected override async Task OnInitializedAsync() + { + Logger.LogDebug("Initializing Agent Chat component"); - // Load agents - try - { - availableAgents = await AgentClient.GetAgentsAsync(); - Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count); + // Load agents + try + { + availableAgents = await AgentClient.GetAgentsAsync(); + Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count); + Logger.LogInformation("Loaded Agents info: {AgentData}", JsonSerializer.Serialize(availableAgents, new JsonSerializerOptions() { WriteIndented = true })); - // Default to first agent and start a conversation - if (availableAgents.Any()) - { - selectedAgentName = availableAgents.First().Name!; - StartNewConversation(); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load agents"); - } - finally - { - isLoadingAgents = false; - } + // Default to first agent and start a conversation + if (availableAgents.Any()) + { + selectedAgentName = availableAgents.First().Name!; + StartNewConversation(); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load agents"); + } + finally + { + isLoadingAgents = false; + } - // Conversations start fresh on page load - } + // Conversations start fresh on page load + } - private string GetAgentIcon(string agentName) - { - return agentName?.ToLower() switch - { - "pirate" => "🏴‍☠️", - "knights-and-knaves" => "⚔️", - _ => "🤖" - }; - } + private string GetAgentIcon(string agentName) + { + return agentName?.ToLower() switch + { + "pirate" => "🏴‍☠️", + "knights-and-knaves" => "⚔️", + _ => "🤖" + }; + } - private string GetAgentDisplayName(string agentName) - { - return agentName?.ToLower() switch - { - "pirate" => "Pirate", - "knights-and-knaves" => "Knights & Knaves", - _ => agentName ?? "Agent" - }; - } + private string GetAgentDisplayName(string agentName) + { + return agentName?.ToLower() switch + { + "pirate" => "Pirate", + "knights-and-knaves" => "Knights & Knaves", + _ => agentName ?? "Agent" + }; + } + private void ToggleA2AExpanded() + { + isA2AExpanded = !isA2AExpanded; + } + private async Task DiscoverAgentCard() + { + if (string.IsNullOrEmpty(selectedAgentName) || isDiscoveringCard) + return; - private void StartNewConversation() - { - if (string.IsNullOrEmpty(selectedAgentName)) - return; + isDiscoveringCard = true; + discoveryError = null; + discoveredAgentCardJson = null; + StateHasChanged(); - var newConversation = new Conversation + try + { + Logger.LogInformation("Discovering agent card for agent: {AgentName}", selectedAgentName); + var agentCard = await A2AActorClient.GetAgentCardAsync(selectedAgentName); + discoveredAgentCardJson = JsonSerializer.Serialize(agentCard, new JsonSerializerOptions() { WriteIndented = true }); + Logger.LogInformation("Successfully discovered agent card for {AgentName}: {CardData}", selectedAgentName, discoveredAgentCardJson); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to discover agent card for {AgentName}", selectedAgentName); + discoveryError = $"Failed to discover agent card: {ex.Message}"; + } + finally + { + isDiscoveringCard = false; + StateHasChanged(); + } + } + + private void StartNewConversation() + { + if (string.IsNullOrEmpty(selectedAgentName)) + return; + + var newConversation = new Conversation { AgentName = selectedAgentName }; - conversations.Add(newConversation); - currentConversation = newConversation; + conversations.Add(newConversation); + currentConversation = newConversation; - Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}", - newConversation.AgentName, newConversation.SessionId); + Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}", + newConversation.AgentName, newConversation.SessionId); - StateHasChanged(); - } + StateHasChanged(); + } - private void SelectConversation(string sessionId) - { - currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId); - if (currentConversation != null) - { - selectedAgentName = currentConversation.AgentName; - Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId); - } - StateHasChanged(); - } + private void SelectConversation(string sessionId) + { + currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (currentConversation != null) + { + selectedAgentName = currentConversation.AgentName; + Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } - private void CloseConversation(string sessionId) - { - var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId); - if (conversationToRemove != null) - { - conversations.Remove(conversationToRemove); + private void CloseConversation(string sessionId) + { + var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (conversationToRemove != null) + { + conversations.Remove(conversationToRemove); - if (currentConversation?.SessionId == sessionId) - { - currentConversation = conversations.FirstOrDefault(); - if (currentConversation != null) - { - selectedAgentName = currentConversation.AgentName; - } - } + if (currentConversation?.SessionId == sessionId) + { + currentConversation = conversations.FirstOrDefault(); + if (currentConversation != null) + { + selectedAgentName = currentConversation.AgentName; + } + } - Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId); - } - StateHasChanged(); - } + Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } - private async Task SendMessage() - { - if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null) - return; + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null) + return; - var userMessage = currentMessage.Trim(); - currentMessage = ""; + var userMessage = currentMessage.Trim(); + currentMessage = ""; - Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}", - userMessage, currentConversation.AgentName, currentConversation.SessionId); + Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}", + userMessage, currentConversation.AgentName, currentConversation.SessionId); - // Add user message to chat - currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage)); - StateHasChanged(); - await ScrollToBottom(); + // Add user message to chat + currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage)); + StateHasChanged(); + await ScrollToBottom(); - // Start streaming response - isStreaming = true; - currentStreamedMessage = ""; - StateHasChanged(); + // Start streaming response + isStreaming = true; + currentStreamedMessage = ""; + StateHasChanged(); - try - { - var responseContent = new StringBuilder(); - var agent = new AgentProxy(currentConversation.AgentName, ActorClient); + StringBuilder responseContent = new(); + var hasReceivedContent = false; + + using var timeoutCts = new CancellationTokenSource( +#if DEBUG + TimeSpan.FromSeconds(120) +#else + TimeSpan.FromSeconds(20) +#endif + ); + + try + { + var actorClient = (selectedProtocol is Protocol.A2A) ? A2AActorClient : ActorClient; + var agent = new AgentProxy(currentConversation.AgentName, actorClient); var thread = agent.GetNewThread(); thread.ConversationId = currentConversation.SessionId; await foreach (var update in agent.RunStreamingAsync( - [new ChatMessage(ChatRole.User, userMessage)], + [new ChatMessage(ChatRole.User, userMessage)], thread, - cancellationToken: CancellationToken.None)) + cancellationToken: timeoutCts.Token)) { var content = update.Text ?? ""; if (!string.IsNullOrEmpty(content)) { + hasReceivedContent = true; responseContent.Append(content); currentStreamedMessage = responseContent.ToString(); StateHasChanged(); await ScrollToBottom(); + + Logger.LogDebug("Received streaming content: {ContentLength} characters", content.Length); } } + Logger.LogInformation("Streaming completed for session {SessionId}, total content length: {ContentLength}", + currentConversation.SessionId, responseContent.Length); + // Add the complete agent response to chat messages if (responseContent.Length > 0) { currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString())); } + else if (!hasReceivedContent) + { + Logger.LogWarning("No content received during streaming for session {SessionId}", currentConversation.SessionId); + currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "No response received from the agent.")); + } else { currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response.")); } } + catch (OperationCanceledException) when (isStreaming) + { + Logger.LogWarning("Streaming operation timed out for session {SessionId}", currentConversation.SessionId); + currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Request timed out. Please try again.")); + } catch (Exception ex) { - Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", + Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", currentConversation.SessionId, ex.Message); currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}")); - } + } finally { isStreaming = false; diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs index 1173f2de29..7d6eae8199 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs @@ -15,8 +15,19 @@ builder.Services.AddRazorComponents() builder.Services.AddOutputCache(); -builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost")); -builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://agenthost")); +// This URL uses "https+http://" to indicate HTTPS is preferred over HTTP. +// Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes. +Uri baseAddress = new("https+http://agenthost"); + +// for some reason does not resolve with `apiservice` url +Uri a2aAddress = new("http://localhost:5390/a2a"); + +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); +builder.Services.AddSingleton(sp => +{ + return new A2AActorClient(sp.GetRequiredService>(), a2aAddress); +}); var app = builder.Build(); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj new file mode 100644 index 0000000000..3dfe651782 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj @@ -0,0 +1,22 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsCoreTargetFrameworks) + $(NoWarn);IDE1006;IDE0130;NU1504 + Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore + alpha + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs new file mode 100644 index 0000000000..504d76037c --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using A2A; +using A2A.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore; + +/// +/// Provides extension methods for configuring A2A (Agent-to-Agent) communication in a host application builder. +/// +public static class WebApplicationExtensions +{ + /// + /// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application. + /// + /// The web application used to configure the pipeline and routes. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + public static void AttachA2A(this WebApplication app, string agentName, string path) + { + var agent = app.Services.GetRequiredKeyedService(agentName); + var loggerFactory = app.Services.GetRequiredService(); + var actorClient = app.Services.GetRequiredService(); + + var taskManager = agent.AttachA2A(actorClient, loggerFactory: loggerFactory); + app.AttachA2A(taskManager, path); + } + + /// + /// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application. + /// + /// The web application used to configure the pipeline and routes. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + public static void AttachA2A( + this WebApplication app, + string agentName, + string path, + AgentCard agentCard) + { + var agent = app.Services.GetRequiredKeyedService(agentName); + var loggerFactory = app.Services.GetRequiredService(); + var actorClient = app.Services.GetRequiredService(); + + var taskManager = agent.AttachA2A(actorClient, agentCard: agentCard, loggerFactory: loggerFactory); + app.AttachA2A(taskManager, path); + } + + /// + /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. + /// TaskManager should be preconfigured before calling this method. + /// + /// The web application used to configure the pipeline and routes. + /// Pre-configured A2A TaskManager to use for A2A endpoints handling. + /// The route group to use for A2A endpoints. + public static void AttachA2A(this WebApplication app, TaskManager taskManager, string path) + { + // note: current SDK version registers multiple `.well-known/agent.json` handlers here. + // it makes app return HTTP 500, but will be fixed once new A2A SDK is released. + // see https://github.com/microsoft/agent-framework/issues/476 for details + app.MapA2A(taskManager, path); + + app.MapHttpA2A(taskManager, path); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs new file mode 100644 index 0000000000..e48e499903 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/AIAgentExtensions.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.AI.Agents.Hosting.A2A.Internal; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A; + +/// +/// Provides extension methods for attaching A2A (Agent-to-Agent) messaging capabilities to an . +/// +public static class AIAgentExtensions +{ + /// + /// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified . + /// + /// Agent to attach A2A messaging processing capabilities to. + /// The actor client implementation to use. + /// Instance of to configure for A2A messaging. New instance will be created if not passed. + /// The logger factory to use for creating instances. + /// The configured . + public static TaskManager AttachA2A( + this AIAgent agent, + IActorClient actorClient, + TaskManager? taskManager = null, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(agent, nameof(agent)); + ArgumentNullException.ThrowIfNull(actorClient, nameof(actorClient)); + + taskManager ??= new(); + + var a2aAgentWrapper = new A2AAgentWrapper(actorClient, agent, loggerFactory); + + taskManager.OnMessageReceived += a2aAgentWrapper.ProcessMessageAsync; + + return taskManager; + } + + /// + /// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified . + /// + /// Agent to attach A2A messaging processing capabilities to. + /// The actor client implementation to use. + /// The agent card to return on query. + /// Instance of to configure for A2A messaging. New instance will be created if not passed. + /// The logger factory to use for creating instances. + /// The configured . + public static TaskManager AttachA2A( + this AIAgent agent, + IActorClient actorClient, + AgentCard agentCard, + TaskManager? taskManager = null, + ILoggerFactory? loggerFactory = null) + { + taskManager = agent.AttachA2A(actorClient, taskManager, loggerFactory); + + taskManager.OnAgentCardQuery += (context, query) => + { + if (agentCard.Url is null) + { + // A2A SDK assigns the url on its own + // we can help user if they did not set Url explicitly. + agentCard.Url = context; + } + + return Task.FromResult(agentCard); + }; + return taskManager; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs new file mode 100644 index 0000000000..e3440ae9b1 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/ActorEntitiesConverter.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI.Agents.Runtime; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters; + +internal static class ActorEntitiesConverter +{ + public static Message ToMessage(this ActorResponse response) + { + var agentRunResponse = response.Data.Deserialize(AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse))) as AgentRunResponse; + if (agentRunResponse is null) + { + throw new ArgumentException("The ActorResponse data could not be deserialized to an AgentRunResponse.", nameof(response)); + } + + var contextId = response.ActorId.Key; + var parts = agentRunResponse.Messages.ToParts(); + + return new Message + { + MessageId = response.MessageId ?? Guid.NewGuid().ToString(), + ContextId = contextId, + Role = MessageRole.Agent, + Parts = parts + }; + } + + public static ActorRequestUpdate ToActorRequestUpdate(this Message message, RequestStatus status = RequestStatus.Completed) + { + // maybe we need to split to chatmessage-per-part, but the idea to map is clear + var chatMessage = message.ToChatMessage(); + if (chatMessage is null) + { + throw new ArgumentException("The Message could not be converted to a ChatMessage.", nameof(message)); + } + + var agentRunResponseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, chatMessage.Contents); + var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); + var jsonElement = JsonSerializer.SerializeToElement(agentRunResponseUpdate, updateTypeInfo); + return new ActorRequestUpdate(status, jsonElement); + } + + public static AgentRunResponse ToAgentRunResponse(this Message message) + { + // maybe we need to split to chatmessage-per-part, but the idea to map is clear + var chatMessage = message.ToChatMessage(); + if (chatMessage is null) + { + throw new ArgumentException("The Message could not be converted to a ChatMessage.", nameof(message)); + } + + return new AgentRunResponse(chatMessage); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs new file mode 100644 index 0000000000..b95c241803 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Converters/MessageConverter.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters; + +internal static class MessageConverter +{ + public static List ToParts(this IList chatMessages) + { + if (chatMessages is null || chatMessages.Count == 0) + { + return []; + } + + var parts = new List(); + foreach (var chatMessage in chatMessages) + { + foreach (var content in chatMessage.Contents) + { + var part = ConvertAIContentToPart(content); + if (part != null) + { + parts.Add(part); + } + } + + // If no parts were created from content, create a text part from the message text + if (chatMessage.Contents.Count == 0 && !string.IsNullOrEmpty(chatMessage.Text)) + { + parts.Add(new TextPart { Text = chatMessage.Text }); + } + } + + return parts; + } + + /// + /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects. + /// + /// The A2A message send parameters to convert. + /// A read-only collection of ChatMessage objects. + public static List ToChatMessages(this MessageSendParams messageSendParams) + { + if (messageSendParams is null) + { + return []; + } + + var result = new List(); + if (messageSendParams.Message?.Parts != null) + { + var chatMessage = ToChatMessage(messageSendParams.Message); + if (chatMessage is not null) + { + result.Add(chatMessage); + } + } + + return result; + } + + /// + /// Converts collection of A2A to a collection of objects. + /// + /// A read-only collection of ChatMessage objects. + public static IReadOnlyCollection ToChatMessages(this ICollection messages) + { + if (messages is null || messages.Count == 0) + { + return []; + } + + var result = new List(); + foreach (var message in messages) + { + var chatMessage = ToChatMessage(message); + if (chatMessage is not null) + { + result.Add(chatMessage); + } + } + + return result; + } + + /// + /// Converts a single to a . + /// + /// The A2A message to convert. + /// A ChatMessage object, or null if conversion is not possible. + public static ChatMessage? ToChatMessage(this Message message) + { + if (message?.Parts == null || message.Parts.Count == 0) + { + return null; + } + + var chatRole = ConvertMessageRoleToChatRole(message.Role); + + var content = new List(); + foreach (var part in message.Parts) + { + var aiContent = ConvertPartToAIContent(part); + if (aiContent is not null) + { + content.Add(aiContent); + } + } + + // If no valid content was extracted, return null + if (content.Count == 0) + { + return null; + } + + // Create the ChatMessage with appropriate metadata + var chatMessage = new ChatMessage(chatRole, content) + { + MessageId = message.MessageId, + RawRepresentation = message + }; + + // Add any additional properties if needed + if (message.Metadata is not null) + { + chatMessage.AdditionalProperties = message.Metadata.ToAdditionalPropertiesDictionary(); + } + + return chatMessage; + } + + /// + /// Converts A2A MessageRole to Microsoft.Extensions.AI ChatRole. + /// + /// The A2A message role. + /// The corresponding ChatRole. + private static ChatRole ConvertMessageRoleToChatRole(MessageRole messageRole) => messageRole switch + { + MessageRole.User => ChatRole.User, + MessageRole.Agent => ChatRole.Assistant, + _ => ChatRole.User + }; + + /// + /// Converts an A2A Part to Microsoft.Extensions.AI AIContent. + /// + /// The A2A part to convert. + /// An AIContent object, or null if conversion is not possible. +#pragma warning disable CA1859 // Use concrete types when possible for improved performance + private static AIContent? ConvertPartToAIContent(Part part) +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + { + var result = part switch + { + TextPart textPart => new TextContent(textPart.Text) + { + RawRepresentation = textPart, + AdditionalProperties = textPart.Metadata?.ToAdditionalPropertiesDictionary() + }, + FilePart or DataPart or _ => throw new NotSupportedException($"Part type '{part.GetType().Name}' is not supported. Only TextPart is supported.") + }; + + return result; + } + + /// + /// Converts Microsoft.Extensions.AI ChatMessage back to A2A Message format. + /// This is useful for the reverse operation. + /// + /// The ChatMessage to convert. + /// An A2A Message object. + public static Message ToA2AMessage(this ChatMessage chatMessage) + { + if (chatMessage == null) + { + throw new ArgumentNullException(nameof(chatMessage)); + } + + var message = new Message + { + MessageId = chatMessage.MessageId ?? System.Guid.NewGuid().ToString(), + Role = ConvertChatRoleToMessageRole(chatMessage.Role), + Parts = new List() + }; + + // Convert content to parts + foreach (var content in chatMessage.Contents) + { + var part = ConvertAIContentToPart(content); + if (part != null) + { + message.Parts.Add(part); + } + } + + // If no parts were created from content, create a text part from the message text + if (message.Parts.Count == 0 && !string.IsNullOrEmpty(chatMessage.Text)) + { + message.Parts.Add(new TextPart { Text = chatMessage.Text }); + } + + return message; + } + + /// + /// Converts Microsoft.Extensions.AI ChatRole to A2A MessageRole. + /// + /// The ChatRole to convert. + /// The corresponding MessageRole. + private static MessageRole ConvertChatRoleToMessageRole(ChatRole chatRole) + { + if (chatRole == ChatRole.User) + { + return MessageRole.User; + } + if (chatRole == ChatRole.Assistant) + { + return MessageRole.Agent; + } + + return MessageRole.User; // Default fallback + } + + /// + /// Converts Microsoft.Extensions.AI AIContent to A2A Part. + /// + /// The AIContent to convert. + /// A Part object, or null if conversion is not possible. +#pragma warning disable CA1859 // Use concrete types when possible for improved performance + private static Part? ConvertAIContentToPart(AIContent content) +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + { + return content switch + { + TextContent textContent => new TextPart + { + Text = textContent.Text + }, + _ => throw new NotSupportedException($"Content type '{content.GetType().Name}' is not supported.") + }; + } + + private static AdditionalPropertiesDictionary? ToAdditionalPropertiesDictionary(this Dictionary metadata) + { + if (metadata == null || metadata.Count == 0) + { + return null; + } + + var additionalProperties = new AdditionalPropertiesDictionary(); + foreach (var kvp in metadata) + { + additionalProperties[kvp.Key] = kvp.Value; + } + return additionalProperties; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs new file mode 100644 index 0000000000..9c97b3d88f --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Internal/A2AAgentWrapper.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters; +using Microsoft.Extensions.AI.Agents.Runtime; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Internal; + +/// +/// A2A agent that wraps an existing AIAgent and provides A2A-specific thread wrapping. +/// +internal sealed class A2AAgentWrapper +{ + private readonly ILogger _logger; + private readonly AIAgent _innerAgent; + private readonly IActorClient _actorClient; + + public A2AAgentWrapper( + IActorClient actorClient, + AIAgent innerAgent, + ILoggerFactory? loggerFactory = null) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + this._actorClient = actorClient; + this._innerAgent = innerAgent ?? throw new ArgumentNullException(nameof(innerAgent)); + } + + public async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString(); + var messageId = messageSendParams.Message.MessageId; + + var actorId = new ActorId(type: this.GetActorType(), key: contextId!); + + // Verify request does not exist already + var existingResponseHandle = await this._actorClient.GetResponseAsync(actorId, messageId, cancellationToken).ConfigureAwait(false); + var existingResponse = await existingResponseHandle.GetResponseAsync(cancellationToken).ConfigureAwait(false); + if (existingResponse.Status is RequestStatus.Completed or RequestStatus.Failed) + { + return existingResponse.ToMessage(); + } + + // here we know we did not yet send the request, so lets do it + var chatMessages = messageSendParams.ToChatMessages(); + var runRequest = new AgentRunRequest + { + Messages = chatMessages + }; + var @params = JsonSerializer.SerializeToElement(runRequest, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunRequest))); + + var requestHandle = await this._actorClient.SendRequestAsync(new ActorRequest(actorId, messageId, method: "Run" /* ?refer to const here? */, @params: @params), cancellationToken).ConfigureAwait(false); + var response = await requestHandle.GetResponseAsync(cancellationToken).ConfigureAwait(false); + + return response.ToMessage(); + } + + private ActorType GetActorType() + { + // agent is registered in DI via name + ArgumentException.ThrowIfNullOrEmpty(this._innerAgent.Name); + return new ActorType(this._innerAgent.Name); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj new file mode 100644 index 0000000000..4b0d9ae271 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj @@ -0,0 +1,33 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsCoreTargetFrameworks) + $(NoWarn);IDE1006;IDE0130;NU1504 + Microsoft.Extensions.AI.Agents.Hosting.A2A + alpha + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs new file mode 100644 index 0000000000..24ce1d3892 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Converters/MessageConverterTests.cs @@ -0,0 +1,531 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI.Agents.Hosting.A2A.Converters; + +namespace Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.Converters; + +public class MessageConverterTests +{ + [Fact] + public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + { + MessageSendParams? messageSendParams = null; + + var result = messageSendParams!.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + { + var messageSendParams = new MessageSendParams + { + Message = null! + }; + + var result = messageSendParams.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + { + var messageSendParams = new MessageSendParams + { + Message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = null! + } + }; + + var result = messageSendParams.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + { + var messageSendParams = new MessageSendParams + { + Message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = new List + { + new TextPart { Text = "Hello, world!" } + } + } + }; + + var result = messageSendParams.ToChatMessages(); + + Assert.NotNull(result); + Assert.Single(result); + + var chatMessage = result.First(); + Assert.Equal("test-id", chatMessage.MessageId); + Assert.Equal(ChatRole.User, chatMessage.Role); + Assert.Single(chatMessage.Contents); + + var textContent = Assert.IsType(chatMessage.Contents.First()); + Assert.Equal("Hello, world!", textContent.Text); + } + + [Fact] + public void ToChatMessages_MessageCollection_Null_ReturnsEmptyCollection() + { + ICollection? messages = null; + + var result = messages!.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToChatMessages_MessageCollection_Empty_ReturnsEmptyCollection() + { + var messages = new List(); + + var result = messages.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToChatMessages_MessageCollection_WithValidMessages_ReturnsCorrectChatMessages() + { + var messages = new List + { + new() + { + MessageId = "user-msg", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "User message" } } + }, + new() + { + MessageId = "agent-msg", + Role = MessageRole.Agent, + Parts = new List { new TextPart { Text = "Agent response" } } + } + }; + + var result = messages.ToChatMessages(); + + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + var userMessage = result.First(); + Assert.Equal("user-msg", userMessage.MessageId); + Assert.Equal(ChatRole.User, userMessage.Role); + Assert.Equal("User message", ((TextContent)userMessage.Contents.First()).Text); + + var agentMessage = result.Skip(1).First(); + Assert.Equal("agent-msg", agentMessage.MessageId); + Assert.Equal(ChatRole.Assistant, agentMessage.Role); + Assert.Equal("Agent response", ((TextContent)agentMessage.Contents.First()).Text); + } + + [Fact] + public void ToChatMessages_MessageCollection_SkipsInvalidMessages_ReturnsValidChatMessages() + { + var messages = new List + { + new() + { + MessageId = "valid-msg", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "Valid message" } } + }, + new() + { + MessageId = "invalid-msg", + Role = MessageRole.User, + Parts = null! // Invalid - no parts + } + }; + + var result = messages.ToChatMessages(); + + Assert.NotNull(result); + Assert.Single(result); + + var validMessage = result.First(); + Assert.Equal("valid-msg", validMessage.MessageId); + Assert.Equal("Valid message", ((TextContent)validMessage.Contents.First()).Text); + } + + [Fact] + public void ToA2AMessage_NullChatMessage_ThrowsArgumentNullException() + { + ChatMessage? chatMessage = null; + + Assert.Throws(() => chatMessage!.ToA2AMessage()); + } + + [Fact] + public void ToA2AMessage_ValidChatMessage_ReturnsCorrectA2AMessage() + { + var chatMessage = new ChatMessage(ChatRole.User, "Hello, world!") + { + MessageId = "test-id" + }; + + var result = chatMessage.ToA2AMessage(); + + Assert.NotNull(result); + Assert.Equal("test-id", result.MessageId); + Assert.Equal(MessageRole.User, result.Role); + Assert.Single(result.Parts); + + var textPart = Assert.IsType(result.Parts.First()); + Assert.Equal("Hello, world!", textPart.Text); + } + + [Fact] + public void ToA2AMessage_ChatMessageWithoutMessageId_GeneratesNewMessageId() + { + var chatMessage = new ChatMessage(ChatRole.Assistant, "Response message"); + + var result = chatMessage.ToA2AMessage(); + + Assert.NotNull(result); + Assert.NotNull(result.MessageId); + Assert.NotEmpty(result.MessageId); + Assert.Equal(MessageRole.Agent, result.Role); + } + + [Fact] + public void ToA2AMessage_ChatMessageWithTextContent_ReturnsCorrectTextPart() + { + var textContent = new TextContent("Test content"); + var chatMessage = new ChatMessage(ChatRole.User, [textContent]); + + var result = chatMessage.ToA2AMessage(); + + Assert.NotNull(result); + Assert.Single(result.Parts); + + var textPart = Assert.IsType(result.Parts.First()); + Assert.Equal("Test content", textPart.Text); + } + + [Fact] + public void ToA2AMessage_ChatMessageWithUnsupportedContent_ThrowsNotSupportedException() + { + var unsupportedContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png"); + var chatMessage = new ChatMessage(ChatRole.User, [unsupportedContent]); + + var exception = Assert.Throws(() => chatMessage.ToA2AMessage()); + Assert.Contains("Content type 'DataContent' is not supported", exception.Message); + } + + [Fact] + public void ToA2AMessage_ChatMessageWithEmptyContent_CreatesTextPartFromMessageText() + { + var chatMessage = new ChatMessage(ChatRole.User, "Fallback text"); + + var result = chatMessage.ToA2AMessage(); + + Assert.NotNull(result); + Assert.Single(result.Parts); + + var textPart = Assert.IsType(result.Parts.First()); + Assert.Equal("Fallback text", textPart.Text); + } + + [Fact] + public void ConvertMessageRoleToChatRole_UserRole_ReturnsUserChatRole() + { + var message = new Message + { + MessageId = "test", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "Test" } } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Equal(ChatRole.User, chatMessage.Role); + } + + [Fact] + public void ConvertMessageRoleToChatRole_AgentRole_ReturnsAssistantChatRole() + { + var message = new Message + { + MessageId = "test", + Role = MessageRole.Agent, + Parts = new List { new TextPart { Text = "Test" } } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Equal(ChatRole.Assistant, chatMessage.Role); + } + + [Fact] + public void ConvertMessageRoleToChatRole_UnknownRole_ReturnsUserChatRole() + { + var message = new Message + { + MessageId = "test", + Role = (MessageRole)999, // Unknown role + Parts = new List { new TextPart { Text = "Test" } } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Equal(ChatRole.User, chatMessage.Role); + } + + [Fact] + public void ConvertChatRoleToMessageRole_UserRole_ReturnsUserMessageRole() + { + var chatMessage = new ChatMessage(ChatRole.User, "Test message"); + + var result = chatMessage.ToA2AMessage(); + + Assert.Equal(MessageRole.User, result.Role); + } + + [Fact] + public void ConvertChatRoleToMessageRole_AssistantRole_ReturnsAgentMessageRole() + { + var chatMessage = new ChatMessage(ChatRole.Assistant, "Test message"); + + var result = chatMessage.ToA2AMessage(); + + Assert.Equal(MessageRole.Agent, result.Role); + } + + [Fact] + public void ConvertChatRoleToMessageRole_SystemRole_ReturnsUserMessageRole() + { + var chatMessage = new ChatMessage(ChatRole.System, "Test message"); + + var result = chatMessage.ToA2AMessage(); + + Assert.Equal(MessageRole.User, result.Role); + } + + [Fact] + public void ConvertChatRoleToMessageRole_ToolRole_ReturnsUserMessageRole() + { + var chatMessage = new ChatMessage(ChatRole.Tool, "Test message"); + + var result = chatMessage.ToA2AMessage(); + + Assert.Equal(MessageRole.User, result.Role); + } + + [Fact] + public void ConvertPartToAIContent_TextPart_ReturnsTextContent() + { + var textPart = new TextPart { Text = "Sample text" }; + var message = new Message + { + MessageId = "test", + Role = MessageRole.User, + Parts = new List { textPart } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + var textContent = Assert.IsType(chatMessage.Contents.First()); + Assert.Equal("Sample text", textContent.Text); + Assert.Equal(textPart, textContent.RawRepresentation); + } + + [Fact] + public void ConvertPartToAIContent_TextPartWithMetadata_PreservesMetadata() + { + var metadata = new Dictionary + { + ["key1"] = JsonDocument.Parse("\"value1\"").RootElement, + ["key2"] = JsonDocument.Parse("42").RootElement + }; + var textPart = new TextPart + { + Text = "Text with metadata", + Metadata = metadata + }; + var message = new Message + { + MessageId = "test", + Role = MessageRole.User, + Parts = new List { textPart } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + var textContent = Assert.IsType(chatMessage.Contents.First()); + Assert.NotNull(textContent.AdditionalProperties); + Assert.Equal(2, textContent.AdditionalProperties.Count); + Assert.True(textContent.AdditionalProperties.ContainsKey("key1")); + Assert.True(textContent.AdditionalProperties.ContainsKey("key2")); + } + + [Fact] + public void ConvertPartToAIContent_FilePart_ThrowsNotSupportedException() + { + var filePart = new FilePart(); + var message = new Message + { + MessageId = "test", + Role = MessageRole.User, + Parts = new List { filePart } + }; + + var exception = Assert.Throws(() => new List { message }.ToChatMessages()); + Assert.Contains("Part type 'FilePart' is not supported", exception.Message); + } + + [Fact] + public void ConvertPartToAIContent_DataPart_ThrowsNotSupportedException() + { + var dataPart = new DataPart(); + var message = new Message + { + MessageId = "test", + Role = MessageRole.User, + Parts = new List { dataPart } + }; + + var exception = Assert.Throws(() => new List { message }.ToChatMessages()); + Assert.Contains("Part type 'DataPart' is not supported", exception.Message); + } + + [Fact] + public void ConvertMessageToChatMessage_WithMetadata_PreservesMetadataInAdditionalProperties() + { + var metadata = new Dictionary + { + ["timestamp"] = JsonDocument.Parse("\"2024-01-01T00:00:00Z\"").RootElement, + ["priority"] = JsonDocument.Parse("1").RootElement + }; + var message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "Test" } }, + Metadata = metadata + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.NotNull(chatMessage.AdditionalProperties); + Assert.Equal(2, chatMessage.AdditionalProperties.Count); + Assert.True(chatMessage.AdditionalProperties.ContainsKey("timestamp")); + Assert.True(chatMessage.AdditionalProperties.ContainsKey("priority")); + } + + [Fact] + public void ConvertMessageToChatMessage_WithRawRepresentation_PreservesOriginalMessage() + { + var message = new Message + { + MessageId = "test-id", + Role = MessageRole.Agent, + Parts = new List { new TextPart { Text = "Test response" } } + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Equal(message, chatMessage.RawRepresentation); + } + + [Fact] + public void ToChatMessages_MessageWithEmptyParts_ReturnsEmptyCollection() + { + var message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = new List() // Empty list + }; + + var result = new List { message }.ToChatMessages(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToA2AMessage_ChatMessageWithMultipleTextContents_CreatesMultipleParts() + { + var contents = new List + { + new TextContent("First part"), + new TextContent("Second part") + }; + var chatMessage = new ChatMessage(ChatRole.User, contents); + + var result = chatMessage.ToA2AMessage(); + + Assert.NotNull(result); + Assert.Equal(2, result.Parts.Count); + + var firstPart = Assert.IsType(result.Parts[0]); + var secondPart = Assert.IsType(result.Parts[1]); + Assert.Equal("First part", firstPart.Text); + Assert.Equal("Second part", secondPart.Text); + } + + [Fact] + public void ToAdditionalPropertiesDictionary_NullMetadata_ReturnsNull() + { + var message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "Test" } }, + Metadata = null + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Null(chatMessage.AdditionalProperties); + } + + [Fact] + public void ToAdditionalPropertiesDictionary_EmptyMetadata_ReturnsNull() + { + var message = new Message + { + MessageId = "test-id", + Role = MessageRole.User, + Parts = new List { new TextPart { Text = "Test" } }, + Metadata = new Dictionary() + }; + + var result = new List { message }.ToChatMessages(); + + var chatMessage = result.First(); + Assert.Null(chatMessage.AdditionalProperties); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj new file mode 100644 index 0000000000..ddc94e3b54 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(NoWarn);NU1504 + + + + + + + + + + + + \ No newline at end of file