mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
a40eda48f1
commit
6ec21859cf
@@ -11,6 +11,7 @@
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);IDE0290;IDE0079;NU5128</NoWarn>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ProjectsCoreTargetFrameworks>net9.0</ProjectsCoreTargetFrameworks>
|
||||
<ProjectsTargetFrameworks>net9.0;net8.0;netstandard2.0;net472</ProjectsTargetFrameworks>
|
||||
<ProjectsDebugTargetFrameworks>net9.0;net472</ProjectsDebugTargetFrameworks>
|
||||
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.61.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.1.151" />
|
||||
<PackageVersion Include="A2A" Version="0.1.0-preview.2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.1.0-preview.2" />
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.74.1" />
|
||||
<!-- Test -->
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.AzureAI/Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Hosting/Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Hosting.A2A/Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore/Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.OpenAI/Microsoft.Extensions.AI.Agents.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj" />
|
||||
@@ -144,6 +146,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
|
||||
<Project Path="tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);IDE1006;IDE0130;NU1504</NoWarn>
|
||||
<RootNamespace>Microsoft.Extensions.AI.Agents.Hosting.A2A.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A.AspNetCore" />
|
||||
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Hosting.A2A\Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+70
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring A2A (Agent-to-Agent) communication in a host application builder.
|
||||
/// </summary>
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="app">The web application used to configure the pipeline and routes.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
public static void AttachA2A(this WebApplication app, string agentName, string path)
|
||||
{
|
||||
var agent = app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
|
||||
var actorClient = app.Services.GetRequiredService<IActorClient>();
|
||||
|
||||
var taskManager = agent.AttachA2A(actorClient, loggerFactory: loggerFactory);
|
||||
app.AttachA2A(taskManager, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent-to-Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="app">The web application used to configure the pipeline and routes.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
public static void AttachA2A(
|
||||
this WebApplication app,
|
||||
string agentName,
|
||||
string path,
|
||||
AgentCard agentCard)
|
||||
{
|
||||
var agent = app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
|
||||
var actorClient = app.Services.GetRequiredService<IActorClient>();
|
||||
|
||||
var taskManager = agent.AttachA2A(actorClient, agentCard: agentCard, loggerFactory: loggerFactory);
|
||||
app.AttachA2A(taskManager, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
|
||||
/// TaskManager should be preconfigured before calling this method.
|
||||
/// </summary>
|
||||
/// <param name="app">The web application used to configure the pipeline and routes.</param>
|
||||
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for attaching A2A (Agent-to-Agent) messaging capabilities to an <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
public static class AIAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <param name="actorClient">The actor client implementation to use.</param>
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent-to-Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <param name="actorClient">The actor client implementation to use.</param>
|
||||
/// <param name="agentCard">The agent card to return on query.</param>
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Part> ToParts(this IList<ChatMessage> chatMessages)
|
||||
{
|
||||
if (chatMessages is null || chatMessages.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var parts = new List<Part>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
|
||||
/// </summary>
|
||||
/// <param name="messageSendParams">The A2A message send parameters to convert.</param>
|
||||
/// <returns>A read-only collection of ChatMessage objects.</returns>
|
||||
public static List<ChatMessage> ToChatMessages(this MessageSendParams messageSendParams)
|
||||
{
|
||||
if (messageSendParams is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ChatMessage>();
|
||||
if (messageSendParams.Message?.Parts != null)
|
||||
{
|
||||
var chatMessage = ToChatMessage(messageSendParams.Message);
|
||||
if (chatMessage is not null)
|
||||
{
|
||||
result.Add(chatMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts collection of A2A <see cref="Message"/> to a collection of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <returns>A read-only collection of ChatMessage objects.</returns>
|
||||
public static IReadOnlyCollection<ChatMessage> ToChatMessages(this ICollection<Message> messages)
|
||||
{
|
||||
if (messages is null || messages.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ChatMessage>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var chatMessage = ToChatMessage(message);
|
||||
if (chatMessage is not null)
|
||||
{
|
||||
result.Add(chatMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a single <see cref="Message"/> to a <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The A2A message to convert.</param>
|
||||
/// <returns>A ChatMessage object, or null if conversion is not possible.</returns>
|
||||
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<AIContent>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts A2A MessageRole to Microsoft.Extensions.AI ChatRole.
|
||||
/// </summary>
|
||||
/// <param name="messageRole">The A2A message role.</param>
|
||||
/// <returns>The corresponding ChatRole.</returns>
|
||||
private static ChatRole ConvertMessageRoleToChatRole(MessageRole messageRole) => messageRole switch
|
||||
{
|
||||
MessageRole.User => ChatRole.User,
|
||||
MessageRole.Agent => ChatRole.Assistant,
|
||||
_ => ChatRole.User
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts an A2A Part to Microsoft.Extensions.AI AIContent.
|
||||
/// </summary>
|
||||
/// <param name="part">The A2A part to convert.</param>
|
||||
/// <returns>An AIContent object, or null if conversion is not possible.</returns>
|
||||
#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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Microsoft.Extensions.AI ChatMessage back to A2A Message format.
|
||||
/// This is useful for the reverse operation.
|
||||
/// </summary>
|
||||
/// <param name="chatMessage">The ChatMessage to convert.</param>
|
||||
/// <returns>An A2A Message object.</returns>
|
||||
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<Part>()
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Microsoft.Extensions.AI ChatRole to A2A MessageRole.
|
||||
/// </summary>
|
||||
/// <param name="chatRole">The ChatRole to convert.</param>
|
||||
/// <returns>The corresponding MessageRole.</returns>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Microsoft.Extensions.AI AIContent to A2A Part.
|
||||
/// </summary>
|
||||
/// <param name="content">The AIContent to convert.</param>
|
||||
/// <returns>A Part object, or null if conversion is not possible.</returns>
|
||||
#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<string, JsonElement> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A2A agent that wraps an existing AIAgent and provides A2A-specific thread wrapping.
|
||||
/// </summary>
|
||||
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<A2AAgentWrapper>();
|
||||
|
||||
this._actorClient = actorClient;
|
||||
this._innerAgent = innerAgent ?? throw new ArgumentNullException(nameof(innerAgent));
|
||||
}
|
||||
|
||||
public async Task<Message> 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);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);IDE1006;IDE0130;NU1504</NoWarn>
|
||||
<RootNamespace>Microsoft.Extensions.AI.Agents.Hosting.A2A</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.A2A.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AgentWebChat.Web" />
|
||||
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+531
@@ -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<Part>
|
||||
{
|
||||
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<TextContent>(chatMessage.Contents.First());
|
||||
Assert.Equal("Hello, world!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageCollection_Null_ReturnsEmptyCollection()
|
||||
{
|
||||
ICollection<Message>? messages = null;
|
||||
|
||||
var result = messages!.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageCollection_Empty_ReturnsEmptyCollection()
|
||||
{
|
||||
var messages = new List<Message>();
|
||||
|
||||
var result = messages.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageCollection_WithValidMessages_ReturnsCorrectChatMessages()
|
||||
{
|
||||
var messages = new List<Message>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MessageId = "user-msg",
|
||||
Role = MessageRole.User,
|
||||
Parts = new List<Part> { new TextPart { Text = "User message" } }
|
||||
},
|
||||
new()
|
||||
{
|
||||
MessageId = "agent-msg",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part> { 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<Message>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MessageId = "valid-msg",
|
||||
Role = MessageRole.User,
|
||||
Parts = new List<Part> { 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<ArgumentNullException>(() => 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<TextPart>(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<TextPart>(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<NotSupportedException>(() => 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<TextPart>(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<Part> { new TextPart { Text = "Test" } }
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part> { new TextPart { Text = "Test" } }
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part> { new TextPart { Text = "Test" } }
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part> { textPart }
|
||||
};
|
||||
|
||||
var result = new List<Message> { message }.ToChatMessages();
|
||||
|
||||
var chatMessage = result.First();
|
||||
var textContent = Assert.IsType<TextContent>(chatMessage.Contents.First());
|
||||
Assert.Equal("Sample text", textContent.Text);
|
||||
Assert.Equal(textPart, textContent.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertPartToAIContent_TextPartWithMetadata_PreservesMetadata()
|
||||
{
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["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<Part> { textPart }
|
||||
};
|
||||
|
||||
var result = new List<Message> { message }.ToChatMessages();
|
||||
|
||||
var chatMessage = result.First();
|
||||
var textContent = Assert.IsType<TextContent>(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<Part> { filePart }
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<NotSupportedException>(() => new List<Message> { 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<Part> { dataPart }
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<NotSupportedException>(() => new List<Message> { message }.ToChatMessages());
|
||||
Assert.Contains("Part type 'DataPart' is not supported", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertMessageToChatMessage_WithMetadata_PreservesMetadataInAdditionalProperties()
|
||||
{
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["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<Part> { new TextPart { Text = "Test" } },
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part> { new TextPart { Text = "Test response" } }
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part>() // Empty list
|
||||
};
|
||||
|
||||
var result = new List<Message> { message }.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMessage_ChatMessageWithMultipleTextContents_CreatesMultipleParts()
|
||||
{
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
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<TextPart>(result.Parts[0]);
|
||||
var secondPart = Assert.IsType<TextPart>(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<Part> { new TextPart { Text = "Test" } },
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
var result = new List<Message> { 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<Part> { new TextPart { Text = "Test" } },
|
||||
Metadata = new Dictionary<string, JsonElement>()
|
||||
};
|
||||
|
||||
var result = new List<Message> { message }.ToChatMessages();
|
||||
|
||||
var chatMessage = result.First();
|
||||
Assert.Null(chatMessage.AdditionalProperties);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);NU1504</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.Hosting.A2A\Microsoft.Extensions.AI.Agents.Hosting.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user