.NET: feat: Microsoft.Extensions.AI.Agents.Hosting.A2A package (#390)

* add timeout handling for message send

* prepare a2a proj

* fix it finally

* add a holder for selected protocol

* init types ;

* see discoveredAgentCardJson

* prettify json

* correct usage

* client setup for card

* setp?

* message:send

* init task based communication

* try call it via the agent thread

* okay i got back the message wooooow!

* nit

* fix duplicates

* yea matey!

* fix knights-knaves for A2A-Task-based communication

* fix a2a agents csproj

* AI feedback

* a2a does not support netstandard / netfx

* try fix build + refactor

* bump a2a for net9 only

* rollback System.Net.ServerSentEvents & Microsoft.Bcl.AsyncInterfaces version upgrade; override in-place and retarget to net9;net8 for A2A

* address PR comments x1

* refactor a2a interfaces

* address PR comments x2

* fix cancel usage

* separate project for A2A.AspNetCore

* simplify

* cleanup

* cleanup dependencies

* generate convertor tests / fix namespaces etc

* setup actor client!

* fix build

* backoff conversations

* fix duplicate message streaming

* address PR comments x1

* remove internalsvisibleto

* dont implement agent card query on my own: give it to the user

* nit

* rename and move projects

* fix dotnet-format

* address PR comments x1

* remove unreferenced project

* rollback

* rename

* nit

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
This commit is contained in:
Korolev Dmitry
2025-08-25 11:14:55 +00:00
committed by GitHub
co-authored by Chris
parent a40eda48f1
commit 6ec21859cf
18 changed files with 1897 additions and 136 deletions
@@ -14,6 +14,7 @@
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore\Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
</ItemGroup>
@@ -29,7 +30,13 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
<!-- A2A dependency -->
<ItemGroup>
<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>
<!-- A2A dependency -->
</Project>
@@ -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");
@@ -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/<agentname>"
private readonly ConcurrentDictionary<string, (A2AClient, A2ACardResolver)> _clients = new();
public A2AActorClient(ILogger logger, Uri baseUri)
{
this._logger = logger;
this._uri = baseUri;
}
public Task<AgentCard> 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<ActorResponseHandle> GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
{
var agentName = request.ActorId.Type;
var (a2aClient, _) = this.ResolveClient(agentName);
return new ValueTask<ActorResponseHandle>(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<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<ActorRequestUpdate> 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<TextContent>()).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);
}
}
}
@@ -5,15 +5,19 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting.A2A\Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
</ItemGroup>
<!-- A2A dependency -->
<ItemGroup>
<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>
<!-- A2A dependency -->
</Project>
@@ -4,6 +4,7 @@
@inject IJSRuntime JSRuntime
@inject ILogger<Home> 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
<PageTitle>Agent Web Chat</PageTitle>
@@ -48,7 +50,105 @@
</div>
</div>
@if (conversations.Any())
<div class="protocol-selection-card">
<label for="protocol-select" class="protocol-select-label">Choose communication protocol:</label>
<div class="protocol-select-wrapper">
<select id="protocol-select" class="protocol-select" @bind="selectedProtocol" disabled="@(isStreaming)">
<option value="AgenticFramework">Agentic Framework</option>
<option value="A2A">A2A (Agent-to-Agent)</option>
</select>
<div class="protocol-info">
@switch (selectedProtocol)
{
case Protocol.A2A:
<span class="protocol-description">🔗 A2A protocol supports long-running agentic processes</span>
break;
case Protocol.AgenticFramework:
default:
<span class="protocol-description">⚡ Direct agentic framework communication</span>
break;
}
</div>
</div>
</div>
@if (selectedProtocol == Protocol.A2A)
{
<div class="a2a-configuration-card">
<div class="a2a-header" @onclick="ToggleA2AExpanded">
<h3 class="a2a-title">
<svg class="a2a-toggle-icon @(isA2AExpanded ? "expanded" : "")" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
A2A Configuration
</h3>
<span class="a2a-subtitle">Discover and configure agent cards</span>
</div>
@if (isA2AExpanded)
{
<div class="a2a-content">
<div class="discover-section">
<button class="discover-btn"
@onclick="DiscoverAgentCard"
disabled="@(string.IsNullOrEmpty(selectedAgentName) || isDiscoveringCard)">
@if (isDiscoveringCard)
{
<div class="spinner-small"></div>
<span>Discovering...</span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<span>Discover Agent Card</span>
}
</button>
@if (!string.IsNullOrEmpty(selectedAgentName))
{
<span class="discover-info">for agent: <strong>@GetAgentDisplayName(selectedAgentName)</strong></span>
}
else
{
<span class="discover-info text-muted">Please select an agent first</span>
}
</div>
@if (discoveredAgentCardJson != null)
{
<div class="agent-card-display">
<h4 class="card-title">🔗 Discovered Agent Card</h4>
<div class="card-details">
<div class="json-container">
<div class="json-header">
<span class="json-label">Agent Card JSON:</span>
</div>
<pre class="json-display"><code>@discoveredAgentCardJson</code></pre>
</div>
</div>
</div>
}
@if (!string.IsNullOrEmpty(discoveryError))
{
<div class="error-display">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
<span>@discoveryError</span>
</div>
}
</div>
}
</div>
}
@if (conversations.Any())
{
<div class="conversations-section">
<div class="conversation-tabs">
@@ -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;
}
}
</style>
@code {
private string currentMessage = "";
private bool isStreaming = false;
private bool isLoadingAgents = true;
private string currentStreamedMessage = "";
private string selectedAgentName = "";
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> 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<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> 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<ChatMessage> Messages { get; set; } = new();
}
private class Conversation
{
public string SessionId { get; set; } = Guid.NewGuid().ToString();
public string AgentName { get; set; } = "";
public List<ChatMessage> 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;
@@ -15,8 +15,19 @@ builder.Services.AddRazorComponents()
builder.Services.AddOutputCache();
builder.Services.AddHttpClient<IActorClient, HttpActorClient>(client => client.BaseAddress = new("https+http://agenthost"));
builder.Services.AddHttpClient<AgentDiscoveryClient>(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<AgentDiscoveryClient>(client => client.BaseAddress = baseAddress);
builder.Services.AddHttpClient<IActorClient, HttpActorClient>(client => client.BaseAddress = baseAddress);
builder.Services.AddSingleton<A2AActorClient>(sp =>
{
return new A2AActorClient(sp.GetRequiredService<ILogger<A2AActorClient>>(), a2aAddress);
});
var app = builder.Build();