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

* Add an A2A client server sample

* Address code review feedback

* Start to fix code review feedback

* Start to fix code review feedback

* Start to fix code review feedback
This commit is contained in:
Mark Wallace
2025-09-09 18:03:10 +01:00
committed by GitHub
Unverified
parent ec5ea3c8a8
commit 745a05d43c
29 changed files with 1550 additions and 14 deletions
+1
View File
@@ -40,6 +40,7 @@
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.8" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.8" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
+5
View File
@@ -7,6 +7,11 @@
<Folder Name="/Samples/">
<File Path="samples/README.md" />
</Folder>
<Folder Name="/Samples/A2AClientServer/">
<File Path="samples/A2AClientServer/README.md" />
<Project Path="samples/A2AClientServer/A2AClient/A2AClient.csproj" />
<Project Path="samples/A2AClientServer/A2AServer/A2AServer.csproj" />
</Folder>
<Folder Name="/Samples/AgentWebChat/">
<Project Path="samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj" />
<Project Path="samples/AgentWebChat/AgentWebChat.AppHost/AgentWebChat.AppHost.csproj" />
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);CS1591;VSTHRD111;CA2007;SKEXP0110</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.A2A\Microsoft.Extensions.AI.Agents.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.OpenAI\Microsoft.Extensions.AI.Agents.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.A2A;
using Microsoft.Extensions.Logging;
using OpenAI;
namespace A2A;
internal sealed class HostClientAgent
{
internal HostClientAgent(ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
this._logger = loggerFactory.CreateLogger("HostClientAgent");
}
internal async Task InitializeAgentAsync(string modelId, string apiKey, string[] agentUrls)
{
try
{
this._logger.LogInformation("Initializing Agent Framework agent with model: {ModelId}", modelId);
// Connect to the remote agents via A2A
var createAgentTasks = agentUrls.Select(agentUrl => this.CreateAgentAsync(agentUrl));
var agents = await Task.WhenAll(createAgentTasks);
var tools = agents.Select(agent => (AITool)AgentAIFunctionFactory.CreateFromAgent(agent)).ToList();
// Create the agent that uses the remote agents as tools
this.Agent = new OpenAIClient(new ApiKeyCredential(apiKey))
.GetChatClient(modelId)
.CreateAIAgent(instructions: "You specialize in handling queries for users and using your tools to provide answers.", name: "HostClient", tools: tools);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Failed to initialize HostClientAgent");
throw;
}
}
/// <summary>
/// The associated <see cref="Agent"/>
/// </summary>
public AIAgent? Agent { get; private set; }
#region private
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private async Task<AIAgent> CreateAgentAsync(string agentUri)
{
var url = new Uri(agentUri);
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(60)
};
var agentCardResolver = new A2ACardResolver(url, httpClient);
return await agentCardResolver.GetAIAgentAsync();
}
#endregion
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Reflection;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace A2A;
public static class Program
{
public static async Task<int> Main(string[] args)
{
// Create root command with options
var rootCommand = new RootCommand("A2AClient");
rootCommand.SetHandler(HandleCommandsAsync);
// Run the command
return await rootCommand.InvokeAsync(args);
}
public static async Task HandleCommandsAsync(InvocationContext context)
{
// Set up the logging
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
var logger = loggerFactory.CreateLogger("A2AClient");
// Retrieve configuration settings
IConfigurationRoot configRoot = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.Build();
var apiKey = configRoot["A2AClient:ApiKey"] ?? throw new ArgumentException("A2AClient:ApiKey must be provided");
var modelId = configRoot["A2AClient:ModelId"] ?? "gpt-4.1";
var agentUrls = configRoot["A2AClient:AgentUrls"] ?? "http://localhost:5000/;http://localhost:5001/;http://localhost:5002/";
// Create the Host agent
var hostAgent = new HostClientAgent(loggerFactory);
await hostAgent.InitializeAgentAsync(modelId, apiKey, agentUrls!.Split(";"));
AgentThread thread = hostAgent.Agent!.GetNewThread();
try
{
while (true)
{
// Get user message
Console.Write("\nUser (:q or quit to exit): ");
string? message = Console.ReadLine();
if (string.IsNullOrWhiteSpace(message))
{
Console.WriteLine("Request cannot be empty.");
continue;
}
if (message == ":q" || message == "quit")
{
break;
}
var agentResponse = await hostAgent.Agent!.RunAsync(message, thread);
foreach (var chatMessage in agentResponse.Messages)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\nAgent: {chatMessage.Text}");
Console.ResetColor();
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while running the A2AClient");
return;
}
}
}
@@ -0,0 +1,26 @@
# A2A Client Sample
Show how to create an A2A Client with a command line interface which invokes agents using the A2A protocol.
## Run the Sample
To run the sample, follow these steps:
1. Run the A2A client:
```bash
cd A2AClient
dotnet run
```
2. Enter your request e.g. "Show me all invoices for Contoso?"
## Set Environment Variables
The agent urls are provided as a ` ` delimited list of strings
```powershell
cd dotnet/samples/A2AClientServer/A2AClient
$env:OPENAI_MODEL_ID="gpt-4o-mini"
$env:OPENAI_API_KEY="<Your OPENAI api key>"
$env:AGENT_URLS="http://localhost:5000/policy;http://localhost:5000/invoice;http://localhost:5000/logistics"
```
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);CS1591;VSTHRD111;CA2007;SKEXP0110</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.A2A\Microsoft.Extensions.AI.Agents.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.AzureAI\Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.OpenAI\Microsoft.Extensions.AI.Agents.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,82 @@
### Each A2A agent is available at a different host address
@hostInvoice = http://localhost:5000
@hostPolicy = http://localhost:5001
@hostLogistics = http://localhost:5002
### Query agent card for the invoice agent
GET {{hostInvoice}}/.well-known/agent.json
### Send a message to the invoice agent
POST {{hostInvoice}}
Content-Type: application/json
{
"id": "1",
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"id": "12345",
"message": {
"role": "user",
"messageId": "msg_1",
"parts": [
{
"kind": "text",
"text": "Show me all invoices for Contoso?"
}
]
}
}
}
### Query agent card for the policy agent
GET {{hostPolicy}}/.well-known/agent.json
### Send a message to the policy agent
POST {{hostPolicy}}
Content-Type: application/json
{
"id": "1",
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"id": "12345",
"message": {
"role": "user",
"messageId": "msg_1",
"parts": [
{
"kind": "text",
"text": "What is the policy for short shipments?"
}
]
}
}
}
### Query agent card for the logistics agent
GET {{hostLogistics}}/.well-known/agent.json
### Send a message to the logistics agent
POST {{hostLogistics}}
Content-Type: application/json
{
"id": "1",
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"id": "12345",
"message": {
"role": "user",
"messageId": "msg_1",
"parts": [
{
"kind": "text",
"text": "What is the status for SHPMT-SAP-001?"
}
]
}
}
}
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.A2A;
using OpenAI;
namespace A2AServer;
internal static class HostAgentFactory
{
internal static async Task<A2AHostAgent> CreateFoundryHostAgentAsync(string agentType, string modelId, string endpoint, string assistantId, IList<AITool>? tools = null)
{
var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential());
PersistentAgent persistentAgent = await persistentAgentsClient.Administration.GetAgentAsync(assistantId);
AIAgent agent = await persistentAgentsClient
.GetAIAgentAsync(persistentAgent.Id, chatOptions: new() { Tools = tools });
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
"INVOICE" => GetInvoiceAgentCard(),
"POLICY" => GetPolicyAgentCard(),
"LOGISTICS" => GetLogisticsAgentCard(),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new A2AHostAgent(agent, agentCard);
}
internal static async Task<A2AHostAgent> CreateChatCompletionHostAgentAsync(string agentType, string modelId, string apiKey, string name, string instructions, IList<AITool>? tools = null)
{
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(modelId)
.CreateAIAgent(instructions, name, tools: tools);
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
"INVOICE" => GetInvoiceAgentCard(),
"POLICY" => GetPolicyAgentCard(),
"LOGISTICS" => GetLogisticsAgentCard(),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new A2AHostAgent(agent, agentCard);
}
#region private
private static AgentCard GetInvoiceAgentCard()
{
var capabilities = new AgentCapabilities()
{
Streaming = false,
PushNotifications = false,
};
var invoiceQuery = new AgentSkill()
{
Id = "id_invoice_agent",
Name = "InvoiceQuery",
Description = "Handles requests relating to invoices.",
Tags = ["invoice", "semantic-kernel"],
Examples =
[
"List the latest invoices for Contoso.",
],
};
return new()
{
Name = "InvoiceAgent",
Description = "Handles requests relating to invoices.",
Version = "1.0.0",
DefaultInputModes = ["text"],
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [invoiceQuery],
};
}
private static AgentCard GetPolicyAgentCard()
{
var capabilities = new AgentCapabilities()
{
Streaming = false,
PushNotifications = false,
};
var invoiceQuery = new AgentSkill()
{
Id = "id_policy_agent",
Name = "PolicyAgent",
Description = "Handles requests relating to policies and customer communications.",
Tags = ["policy", "semantic-kernel"],
Examples =
[
"What is the policy for short shipments?",
],
};
return new AgentCard()
{
Name = "PolicyAgent",
Description = "Handles requests relating to policies and customer communications.",
Version = "1.0.0",
DefaultInputModes = ["text"],
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [invoiceQuery],
};
}
private static AgentCard GetLogisticsAgentCard()
{
var capabilities = new AgentCapabilities()
{
Streaming = false,
PushNotifications = false,
};
var logisticsQuery = new AgentSkill()
{
Id = "id_logistics_agent",
Name = "LogisticsQuery",
Description = "Handles requests relating to logistics.",
Tags = ["logistics", "semantic-kernel"],
Examples =
[
"What is the status for SHPMT-SAP-001",
],
};
return new AgentCard()
{
Name = "LogisticsAgent",
Description = "Handles requests relating to logistics.",
Version = "1.0.0",
DefaultInputModes = ["text"],
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [logisticsQuery],
};
}
#endregion
}
@@ -0,0 +1,176 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace A2A;
/// <summary>
/// A simple invoice plugin that returns mock data.
/// </summary>
public class Product
{
public string Name { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; } // Price per unit
public Product(string name, int quantity, decimal price)
{
this.Name = name;
this.Quantity = quantity;
this.Price = price;
}
public decimal TotalPrice()
{
return this.Quantity * this.Price; // Total price for this product
}
}
public class Invoice
{
public string TransactionId { get; set; }
public string InvoiceId { get; set; }
public string CompanyName { get; set; }
public DateTime InvoiceDate { get; set; }
public List<Product> Products { get; set; } // List of products
public Invoice(string transactionId, string invoiceId, string companyName, DateTime invoiceDate, List<Product> products)
{
this.TransactionId = transactionId;
this.InvoiceId = invoiceId;
this.CompanyName = companyName;
this.InvoiceDate = invoiceDate;
this.Products = products;
}
public decimal TotalInvoicePrice()
{
return this.Products.Sum(product => product.TotalPrice()); // Total price of all products in the invoice
}
}
public class InvoiceQuery
{
private readonly List<Invoice> _invoices;
private static readonly Random s_random = new();
public InvoiceQuery()
{
// Extended mock data with quantities and prices
this._invoices =
[
new("TICKET-XYZ987", "INV789", "Contoso", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 150, 10.00m),
new("Hats", 200, 15.00m),
new("Glasses", 300, 5.00m)
}),
new("TICKET-XYZ111", "INV111", "XStore", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 2500, 12.00m),
new("Hats", 1500, 8.00m),
new("Glasses", 200, 20.00m)
}),
new("TICKET-XYZ222", "INV222", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 1200, 14.00m),
new("Hats", 800, 7.00m),
new("Glasses", 500, 25.00m)
}),
new("TICKET-XYZ333", "INV333", "Contoso", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 400, 11.00m),
new("Hats", 600, 15.00m),
new("Glasses", 700, 5.00m)
}),
new("TICKET-XYZ444", "INV444", "XStore", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 800, 10.00m),
new("Hats", 500, 18.00m),
new("Glasses", 300, 22.00m)
}),
new("TICKET-XYZ555", "INV555", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 1100, 9.00m),
new("Hats", 900, 12.00m),
new("Glasses", 1200, 15.00m)
}),
new("TICKET-XYZ666", "INV666", "Contoso", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 2500, 8.00m),
new("Hats", 1200, 10.00m),
new("Glasses", 1000, 6.00m)
}),
new("TICKET-XYZ777", "INV777", "XStore", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 1900, 13.00m),
new("Hats", 1300, 16.00m),
new("Glasses", 800, 19.00m)
}),
new("TICKET-XYZ888", "INV888", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 2200, 11.00m),
new("Hats", 1700, 8.50m),
new("Glasses", 600, 21.00m)
}),
new("TICKET-XYZ999", "INV999", "Contoso", GetRandomDateWithinLastTwoMonths(), new List<Product>
{
new("T-Shirts", 1400, 10.50m),
new("Hats", 1100, 9.00m),
new("Glasses", 950, 12.00m)
})
];
}
public static DateTime GetRandomDateWithinLastTwoMonths()
{
// Get the current date and time
DateTime endDate = DateTime.Now;
// Calculate the start date, which is two months before the current date
DateTime startDate = endDate.AddMonths(-2);
// Generate a random number of days between 0 and the total number of days in the range
int totalDays = (endDate - startDate).Days;
#pragma warning disable CA5394 // Do not use insecure randomness
int randomDays = s_random.Next(0, totalDays + 1); // +1 to include the end date
#pragma warning restore CA5394 // Do not use insecure randomness
// Return the random date
return startDate.AddDays(randomDays);
}
[Description("Retrieves invoices for the specified company and optionally within the specified time range")]
public IEnumerable<Invoice> QueryInvoices(string companyName, DateTime? startDate = null, DateTime? endDate = null)
{
var query = this._invoices.Where(i => i.CompanyName.Equals(companyName, StringComparison.OrdinalIgnoreCase));
if (startDate.HasValue)
{
query = query.Where(i => i.InvoiceDate >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(i => i.InvoiceDate <= endDate.Value);
}
return query.ToList();
}
[Description("Retrieves invoice using the transaction id")]
public IEnumerable<Invoice> QueryByTransactionId(string transactionId)
{
var query = this._invoices.Where(i => i.TransactionId.Equals(transactionId, StringComparison.OrdinalIgnoreCase));
return query.ToList();
}
[Description("Retrieves invoice using the invoice id")]
public IEnumerable<Invoice> QueryByInvoiceId(string invoiceId)
{
var query = this._invoices.Where(i => i.InvoiceId.Equals(invoiceId, StringComparison.OrdinalIgnoreCase));
return query.ToList();
}
}
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
using A2A.AspNetCore;
using A2AServer;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.A2A;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
string agentId = string.Empty;
string agentType = string.Empty;
for (var i = 0; i < args.Length; i++)
{
if (args[i].StartsWith("--agentId", StringComparison.InvariantCultureIgnoreCase) && i + 1 < args.Length)
{
agentId = args[++i];
}
else if (args[i].StartsWith("--agentType", StringComparison.InvariantCultureIgnoreCase) && i + 1 < args.Length)
{
agentType = args[++i];
}
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
var app = builder.Build();
var httpClient = app.Services.GetRequiredService<IHttpClientFactory>().CreateClient();
var logger = app.Logger;
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets<Program>()
.Build();
string? apiKey = configuration["OPENAI_API_KEY"];
string modelId = configuration["OPENAI_MODEL_ID"] ?? "gpt-4o-mini";
string? endpoint = configuration["FOUNDRY_PROJECT_ENDPOINT"];
var invoiceQueryPlugin = new InvoiceQuery();
IList<AITool> tools =
[
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
];
A2AHostAgent? hostAgent = null;
if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentId))
{
hostAgent = agentType.ToUpperInvariant() switch
{
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, modelId, endpoint, agentId, tools),
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, modelId, endpoint, agentId),
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, modelId, endpoint, agentId),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
else if (!string.IsNullOrEmpty(apiKey))
{
hostAgent = agentType.ToUpperInvariant() switch
{
"INVOICE" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, modelId, apiKey, "InvoiceAgent",
"""
You specialize in handling queries related to invoices.
""", tools),
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, modelId, apiKey, "PolicyAgent",
"""
You specialize in handling queries related to policies and customer communications.
Always reply with exactly this text:
Policy: Short Shipment Dispute Handling Policy V2.1
Summary: "For short shipments reported by customers, first verify internal shipment records
(SAP) and physical logistics scan data (BigQuery). If discrepancy is confirmed and logistics data
shows fewer items packed than invoiced, issue a credit for the missing items. Document the
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
template."
"""),
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, modelId, apiKey, "LogisticsAgent",
"""
You specialize in handling queries related to logistics.
Always reply with exactly:
Shipment number: SHPMT-SAP-001
Item: TSHIRT-RED-L
Quantity: 900
"""),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
else
{
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentId must be provided");
}
app.MapA2A(hostAgent!.TaskManager!, "/");
await app.RunAsync();
+234
View File
@@ -0,0 +1,234 @@
# A2A Client and Server samples
> **Warning**
> The [A2A protocol](https://google.github.io/A2A/) is still under development and changing fast.
> We will try to keep these samples updated as the protocol evolves.
These samples are built with [official A2A C# SDK](https://www.nuget.org/packages/A2A) and demonstrates:
1. Creating an A2A Server which makes an agent available via the A2A protocol.
2. Creating an A2A Client with a command line interface which invokes agents using the A2A protocol.
The demonstration has two components:
1. `A2AServer` - You will run three instances of the server to correspond to three A2A servers each providing a single Agent i.e., the Invoice, Policy and Logistics agents.
2. `A2AClient` - This represents a client application which will connect to the remote A2A servers using the A2A protocol so that it can use those agents when answering questions you will ask.
<img src="./demo-architecture.png" alt="Demo Architecture"/>
## Configuring Environment Variables
The samples can be configured to use chat completion agents or Azure AI agents.
### Configuring for use with Chat Completion Agents
Provide your OpenAI API key via an environment variable
```powershell
$env:OPENAI_API_KEY="<Your OpenAI API Key>"
```
Use the following commands to run each A2A server:
Execute the following command to build the sample:
```powershell
cd A2AServer
dotnet build
```
```bash
dotnet run --urls "http://localhost:5000;https://localhost:5010" --agentType "invoice" --no-build
```
```bash
dotnet run --urls "http://localhost:5001;https://localhost:5011" --agentType "policy" --no-build
```
```bash
dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentType "logistics" --no-build
```
### Configuring for use with Azure AI Agents
You must create the agents in an Azure AI Foundry project and then provide the project endpoint and agents ids. The instructions for each agent are as follows:
- Invoice Agent
```
You specialize in handling queries related to invoices.
```
- Policy Agent
```
You specialize in handling queries related to policies and customer communications.
Always reply with exactly this text:
Policy: Short Shipment Dispute Handling Policy V2.1
Summary: "For short shipments reported by customers, first verify internal shipment records
(SAP) and physical logistics scan data (BigQuery). If discrepancy is confirmed and logistics data
shows fewer items packed than invoiced, issue a credit for the missing items. Document the
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
template."
```
- Logistics Agent
```
You specialize in handling queries related to logistics.
Always reply with exactly:
Shipment number: SHPMT-SAP-001
Item: TSHIRT-RED-L
Quantity: 900"
```
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://ai-foundry-your-project.services.ai.azure.com/api/projects/ai-proj-ga-your-project" # Replace with your Foundry Project endpoint
```
Use the following commands to run each A2A server
```bash
dotnet run --urls "http://localhost:5000;https://localhost:5010" --agentId "<Invoice Agent Id>" --agentType "invoice" --no-build
```
```bash
dotnet run --urls "http://localhost:5001;https://localhost:5011" --agentId "<Policy Agent Id>" --agentType "policy" --no-build
```
```bash
dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentId "<Logistics Agent Id>" --agentType "logistics" --no-build
```
### Testing the Agents using the Rest Client
This sample contains a [.http file](https://learn.microsoft.com/aspnet/core/test/http-files?view=aspnetcore-9.0) which can be used to test the agent.
1. In Visual Studio open [./A2AServer/A2AServer.http](./A2AServer/A2AServer.http)
1. There are two sent requests for each agent, e.g., for the invoice agent:
1. Query agent card for the invoice agent
`GET {{hostInvoice}}/.well-known/agent.json`
1. Send a message to the invoice agent
```
POST {{hostInvoice}}
Content-Type: application/json
{
"id": "1",
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"id": "12345",
"message": {
"role": "user",
"messageId": "msg_1",
"parts": [
{
"kind": "text",
"text": "Show me all invoices for Contoso?"
}
]
}
}
}
```
Sample output from the request to display the agent card:
<img src="./rest-client-agent-card.png" alt="Agent Card"/>
Sample output from the request to send a message to the agent via A2A protocol:
<img src="./rest-client-send-message.png" alt="Send Message"/>
### Testing the Agents using the A2A Inspector
The A2A Inspector is a web-based tool designed to help developers inspect, debug, and validate servers that implement the Google A2A (Agent-to-Agent) protocol. It provides a user-friendly interface to interact with an A2A agent, view communication, and ensure specification compliance.
For more information go [here](https://github.com/a2aproject/a2a-inspector).
Running the [inspector with Docker](https://github.com/a2aproject/a2a-inspector?tab=readme-ov-file#option-two-run-with-docker) is the easiest way to get started.
1. Navigate to the A2A Inspector in your browser: [http://127.0.0.1:8080/](http://127.0.0.1:8080/)
1. Enter the URL of the Agent you are running e.g., [http://host.docker.internal:5000](http://host.docker.internal:5000)
1. Connect to the agent and the agent card will be displayed and validated.
1. Type a message and send it to the agent using A2A protocol.
1. The response will be validated automatically and then displayed in the UI.
1. You can select the response to view the raw json.
Agent card after connecting to an agent using the A2A protocol:
<img src="./a2a-inspector-agent-card.png" alt="Agent Card"/>
Sample response after sending a message to the agent via A2A protocol:
<img src="./a2a-inspector-send-message.png" alt="Send Message"/>
Raw JSON response from an A2A agent:
<img src="./a2a-inspector-raw-json-response.png" alt="Response Raw JSON"/>
### Configuring Agents for the A2A Client
The A2A client will connect to remote agents using the A2A protocol.
By default the client will connect to the invoice, policy and logistics agents provided by the sample A2A Server.
These are available at the following URL's:
- Invoice Agent: http://localhost:5000/
- Policy Agent: http://localhost:5001/
- Logistics Agent: http://localhost:5002/
If you want to change which agents are using then set the agents url as a space delimited string as follows:
```powershell
$env:A2A_AGENT_URLS="http://localhost:5000/;http://localhost:5001/;http://localhost:5002/"
```
## Run the Sample
To run the sample, follow these steps:
1. Run the A2A server's using the commands shown earlier
2. Run the A2A client:
```bash
cd A2AClient
dotnet run
```
3. Enter your request e.g. "Customer is disputing transaction TICKET-XYZ987 as they claim the received fewer t-shirts than ordered."
4. The host client agent will call the remote agents, these calls will be displayed as console output. The final answer will use information from the remote agents. The sample below includes all three agents but in your case you may only see the policy and invoice agent.
Sample output from the A2A client:
```
A2AClient> dotnet run
info: HostClientAgent[0]
Initializing Agent Framework agent with model: gpt-4o-mini
User (:q or quit to exit): Customer is disputing transaction TICKET-XYZ987 as they claim the received fewer t-shirts than ordered.
Agent:
Agent:
Agent: The transaction details for **TICKET-XYZ987** are as follows:
- **Invoice ID:** INV789
- **Company Name:** Contoso
- **Invoice Date:** September 4, 2025
- **Products:**
- **T-Shirts:** 150 units at $10.00 each
- **Hats:** 200 units at $15.00 each
- **Glasses:** 300 units at $5.00 each
To proceed with the dispute regarding the quantity of t-shirts delivered, please specify the exact quantity issue how many t-shirts were actually received compared to the ordered amount.
### Customer Service Policy for Handling Disputes
**Short Shipment Dispute Handling Policy V2.1**
- **Summary:** For short shipments reported by customers, first verify internal shipment records and physical logistics scan data. If a discrepancy is confirmed and the logistics data shows fewer items were packed than invoiced, a credit for the missing items will be issued.
- **Follow-up Actions:** Document the resolution in the SAP CRM and notify the customer via email within 2 business days, referencing the original invoice and the credit memo number, using the 'Formal Credit Notification' email template.
Please provide me with the information regarding the specific quantity issue so I can assist you further.
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

@@ -69,7 +69,7 @@ internal sealed class A2AAgent : AIAgent
if (a2aResponse is Message message)
{
UpdateThreadConversationId(thread, message);
UpdateThreadConversationId(thread, message.ContextId);
return new AgentRunResponse
{
@@ -80,8 +80,21 @@ internal sealed class A2AAgent : AIAgent
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
};
}
if (a2aResponse is AgentTask agentTask)
{
UpdateThreadConversationId(thread, agentTask.ContextId);
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
return new AgentRunResponse
{
AgentId = this.Id,
ResponseId = agentTask.Id,
RawRepresentation = agentTask,
Messages = agentTask.ToChatMessages(),
AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(),
};
}
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
}
/// <inheritdoc/>
@@ -107,7 +120,7 @@ internal sealed class A2AAgent : AIAgent
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}");
}
UpdateThreadConversationId(thread, message);
UpdateThreadConversationId(thread, message.ContextId);
yield return new AgentRunResponseUpdate
{
@@ -147,22 +160,22 @@ internal sealed class A2AAgent : AIAgent
}
}
private static void UpdateThreadConversationId(AgentThread? thread, Message message)
private static void UpdateThreadConversationId(AgentThread? thread, string? contextId)
{
if (thread is null)
{
return;
}
// Surface cases where the A2A agent responds with a message that
// Surface cases where the A2A agent responds with a response that
// has a different context Id than the thread's conversation Id.
if (thread.ConversationId is not null && message.ContextId is not null && thread.ConversationId != message.ContextId)
if (thread.ConversationId is not null && contextId is not null && thread.ConversationId != contextId)
{
throw new InvalidOperationException(
$"The {nameof(message.ContextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
$"The {nameof(contextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
}
// Assign a server-generated context Id to the thread if it's not already set.
thread.ConversationId ??= message.ContextId;
thread.ConversationId ??= contextId;
}
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Host which will attach an <see cref="AIAgent"/> to a <see cref="ITaskManager"/>
/// </summary>
/// <remarks>
/// This implementation only handles:
/// <list type="bullet">
/// <item><code>TaskManager.OnMessageReceived</code></item>
/// <item><code>TaskManager.OnAgentCardQuery</code></item>
/// </list>
/// Support for task management will be added later as part of the long-running task execution work.
/// </remarks>
public sealed class A2AHostAgent
{
/// <summary>
/// Initializes a new instance of the SemanticKernelTravelAgent
/// </summary>
public A2AHostAgent(AIAgent agent, AgentCard agentCard, TaskManager? taskManager = null)
{
Throw.IfNull(agent);
Throw.IfNull(agentCard);
this.Agent = agent;
this._agentCard = agentCard;
this.Attach(taskManager ?? new TaskManager());
}
/// <summary>
/// The associated <see cref="AIAgent"/>
/// </summary>
public AIAgent? Agent { get; private set; }
/// <summary>
/// The associated <see cref="ITaskManager"/>
/// </summary>
public TaskManager? TaskManager => this._taskManager;
/// <summary>
/// Attach the <see cref="A2AAgent"/> to the provided <see cref="ITaskManager"/>
/// </summary>
/// <param name="taskManager"></param>
public void Attach(TaskManager taskManager)
{
Throw.IfNull(taskManager);
this._taskManager = taskManager;
taskManager.OnMessageReceived = this.OnMessageReceivedAsync;
taskManager.OnAgentCardQuery = this.GetAgentCardAsync;
}
/// <summary>
/// Handle a received message
/// </summary>
/// <param name="messageSend">The <see cref="MessageSendParams"/> to handle</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the operation</param>
public async Task<Message> OnMessageReceivedAsync(MessageSendParams messageSend, CancellationToken cancellationToken = default)
{
Throw.IfNull(messageSend);
Throw.IfNull(this.Agent);
if (this._taskManager is null)
{
throw new InvalidOperationException("TaskManager must be attached before handling an agent message.");
}
// Get message from the user
var userMessage = messageSend.Message.ToChatMessage();
// Get the response from the agent
var message = new Message();
var agentResponse = await this.Agent.RunAsync(userMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var chatMessage in agentResponse.Messages)
{
var content = chatMessage.Text;
message.Parts.Add(new TextPart() { Text = content! });
}
return message;
}
/// <summary>
/// Return the <see cref="AgentCard"/> associated with this hosted agent.
/// </summary>
/// <param name="agentUrl">Current URL for the agent</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the operation</param>
#pragma warning disable CA1054 // URI-like parameters should not be strings
public Task<AgentCard> GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
{
// Ensure the URL is in the correct format
Uri uri = new(agentUrl);
agentUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
this._agentCard.Url = agentUrl;
return Task.FromResult(this._agentCard);
}
#pragma warning restore CA1054 // URI-like parameters should not be strings
#region private
private readonly AgentCard _agentCard;
private TaskManager? _taskManager;
#endregion
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="AgentTask"/> class.
/// </summary>
internal static class A2AAgentTaskExtensions
{
internal static IList<ChatMessage> ToChatMessages(this AgentTask agentTask)
{
_ = Throw.IfNull(agentTask);
List<ChatMessage> messages = [];
if (agentTask.Artifacts is not null)
{
foreach (var artifact in agentTask.Artifacts)
{
messages.Add(artifact.ToChatMessage());
}
}
return messages;
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Extension methods for the <see cref="Artifact"/> class.
/// </summary>
internal static class A2AArtifactExtensions
{
internal static ChatMessage ToChatMessage(this Artifact artifact)
{
List<AIContent>? aiContents = null;
foreach (var part in artifact.Parts)
{
(aiContents ??= []).Add(part.ToAIContent());
}
return new ChatMessage(ChatRole.Assistant, aiContents)
{
AdditionalProperties = artifact.Metadata.ToAdditionalProperties(),
RawRepresentation = artifact,
};
}
}
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.A2A;
/// </summary>
internal static class A2AMessageExtensions
{
public static ChatMessage ToChatMessage(this Message message)
internal static ChatMessage ToChatMessage(this Message message)
{
List<AIContent>? aiContents = null;
@@ -15,7 +15,7 @@ internal static class A2AMetadataExtensions
/// </summary>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
public static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
@@ -15,7 +15,7 @@ internal static class A2APartExtensions
/// </summary>
/// <param name="part">The A2A part to convert.</param>
/// <returns>The corresponding <see cref="AIContent"/>.</returns>
public static AIContent ToAIContent(this Part part)
internal static AIContent ToAIContent(this Part part)
{
return part switch
{
@@ -16,7 +16,7 @@ internal static class AIContentExtensions
/// </summary>
/// <param name="contents">The collection of AI contents to convert.</param>"
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
public static List<Part>? ToA2AParts(this IEnumerable<AIContent> contents)
internal static List<Part>? ToA2AParts(this IEnumerable<AIContent> contents)
{
List<Part>? parts = null;
@@ -33,7 +33,7 @@ internal static class AIContentExtensions
/// </summary>
/// <param name="content">AI content to convert.</param>
/// <returns>The corresponding A2A <see cref="Part"/> object.</returns>
public static Part ToA2APart(this AIContent content)
internal static Part ToA2APart(this AIContent content)
{
return content switch
{
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.A2A;
/// </summary>
internal static class ChatMessageExtensions
{
public static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
internal static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
{
List<Part> allParts = [];
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using static Microsoft.Extensions.AI.Agents.OpenTelemetryConsts.GenAI;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Provides factory methods for creating implementations of <see cref="AIFunction"/> backed by an <see cref="AIAgent" />.
/// </summary>
public static class AgentAIFunctionFactory
{
/// <summary>
/// Creates a <see cref="AIFunction"/> that will invoke the provided Agent.
/// </summary>
/// <param name="agent">The <see cref="Agent" /> to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="agent"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking the <see cref="AIAgent"/>.</returns>
public static AIFunction CreateFromAgent(
AIAgent agent,
AIFunctionFactoryOptions? options = null)
{
Throw.IfNull(agent);
async Task<string> RunAgentAsync(string query, CancellationToken cancellationToken)
{
var response = await agent.RunAsync(query, cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Text;
}
return AIFunctionFactory.Create(RunAgentAsync, options ?? new()
{
Name = agent.Name,
Description = agent.Description,
});
}
}
@@ -0,0 +1,339 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Extensions.AI.Agents.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentAIFunctionFactory"/> class.
/// </summary>
public class AgentAIFunctionFactoryTests
{
[Fact]
public void CreateFromAgent_WithNullAgent_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
AgentAIFunctionFactory.CreateFromAgent(null!));
Assert.Equal("agent", exception.ParamName);
}
[Fact]
public void CreateFromAgent_WithValidAgent_ReturnsAIFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
Assert.True(result is AIFunction);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullName_UsesDefaultName()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullDescription_UsesEmptyDescription()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns((string?)null);
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal(string.Empty, result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptions_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
var customOptions = new AIFunctionFactoryOptions
{
Name = "CustomName",
Description = "Custom description"
};
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object, customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("CustomName", result.Name);
Assert.Equal("Custom description", result.Description);
}
[Fact]
public void CreateFromAgent_WithNullOptions_UsesAgentProperties()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object, null);
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedAsync_CallsAgentRunAsync()
{
// Arrange
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = AgentAIFunctionFactory.CreateFromAgent(testAgent);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedWithCancellationTokenAsync_PassesCancellationTokenAsync()
{
// Arrange
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
using var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
var aiFunction = AgentAIFunctionFactory.CreateFromAgent(testAgent);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments, cancellationToken);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenAgentThrowsExceptionAsync_PropagatesExceptionAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Test exception");
var testAgent = new TestAgent("TestAgent", "Test description", expectedException);
var aiFunction = AgentAIFunctionFactory.CreateFromAgent(testAgent);
// Act & Assert
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await aiFunction.InvokeAsync(arguments));
Assert.Same(expectedException, actualException);
}
[Fact]
public void CreateFromAgent_ReturnsInvokableFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
// Verify the function has the expected parameter schema
var parameters = result.JsonSchema;
// Verify it has a query parameter
Assert.True(parameters.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("query", out var queryProperty));
Assert.True(queryProperty.TryGetProperty("type", out var typeProperty));
Assert.Equal("string", typeProperty.GetString());
}
[Fact]
public void CreateFromAgent_WithEmptyAgentName_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns(string.Empty);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
Assert.Equal(string.Empty, result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithEmptyAgentDescription_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns(string.Empty);
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object);
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal(string.Empty, result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptionsOverridingNullAgentProperties_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns((string?)null);
var customOptions = new AIFunctionFactoryOptions
{
Name = "OverrideName",
Description = "Override description"
};
// Act
var result = AgentAIFunctionFactory.CreateFromAgent(mockAgent.Object, customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("OverrideName", result.Name);
Assert.Equal("Override description", result.Description);
}
[Fact]
public async Task CreateFromAgent_InvokeWithComplexResponseFromAgentAsync_ReturnsCorrectResponseAsync()
{
// Arrange
var expectedResponse = new AgentRunResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = AgentAIFunctionFactory.CreateFromAgent(testAgent);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
}
/// <summary>
/// Test implementation of AIAgent for testing purposes.
/// </summary>
private sealed class TestAgent : AIAgent
{
private readonly AgentRunResponse? _responseToReturn;
private readonly Exception? _exceptionToThrow;
public TestAgent(string? name, string? description, AgentRunResponse responseToReturn)
{
this.Name = name;
this.Description = description;
this._responseToReturn = responseToReturn;
}
public TestAgent(string? name, string? description, Exception exceptionToThrow)
{
this.Name = name;
this.Description = description;
this._exceptionToThrow = exceptionToThrow;
}
public override string? Name { get; }
public override string? Description { get; }
public List<ChatMessage> ReceivedMessages { get; } = new();
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
public override Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
this.RunAsyncCallCount++;
this.LastCancellationToken = cancellationToken;
this.ReceivedMessages.AddRange(messages);
if (this._exceptionToThrow != null)
{
throw this._exceptionToThrow;
}
return Task.FromResult(this._responseToReturn!);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await this.RunAsync(messages, thread, options, cancellationToken);
foreach (var update in response.ToAgentRunResponseUpdates())
{
yield return update;
}
}
}
}