chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
using System.ClientModel;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
internal sealed class HostClientAgent
|
||||
{
|
||||
internal HostClientAgent(ILoggerFactory 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(CreateAgentAsync);
|
||||
var agents = await Task.WhenAll(createAgentTasks);
|
||||
var tools = agents.Select(agent => (AITool)agent.AsAIFunction()).ToList();
|
||||
|
||||
// Create the agent that uses the remote agents as tools
|
||||
this.Agent = new OpenAIClient(new ApiKeyCredential(apiKey))
|
||||
.GetChatClient(modelId)
|
||||
.AsAIAgent(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 ILogger _logger;
|
||||
|
||||
private static 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,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Reflection;
|
||||
using Microsoft.Agents.AI;
|
||||
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.SetAction((_, ct) => HandleCommandsAsync(ct));
|
||||
|
||||
// Run the command
|
||||
return await rootCommand.Parse(args).InvokeAsync();
|
||||
}
|
||||
|
||||
private static async Task HandleCommandsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 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-5.4-mini";
|
||||
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(";"));
|
||||
AgentSession session = await hostAgent.Agent!.CreateSessionAsync(cancellationToken);
|
||||
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 is ":q" or "quit")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\nAgent: {agentResponse.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/05-end-to-end/A2AClientServer/A2AClient
|
||||
|
||||
$env:OPENAI_CHAT_MODEL_NAME="gpt-5.4-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,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,85 @@
|
||||
### 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-card.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": {
|
||||
"kind": "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-card.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": {
|
||||
"kind": "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-card.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": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_1",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "What is the status for SHPMT-SAP-001?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using A2A;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
using AgentCard = A2A.AgentCard;
|
||||
|
||||
namespace A2AServer;
|
||||
|
||||
internal static class HostAgentFactory
|
||||
{
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
ProjectsAgentRecord agentRecord = await aiProjectClient.AgentAdministrationClient.GetAgentAsync(agentName);
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(agentRecord, tools: tools);
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
return new(agent, agentCard);
|
||||
}
|
||||
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
AIAgent agent = new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
.AsAIAgent(instructions, name, tools: tools);
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
return new(agent, agentCard);
|
||||
}
|
||||
|
||||
#region private
|
||||
private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
Streaming = false,
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var invoiceQuery = new A2A.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],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetPolicyAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
Streaming = false,
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var policyQuery = new A2A.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 = [policyQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
Streaming = false,
|
||||
PushNotifications = false,
|
||||
};
|
||||
|
||||
var logisticsQuery = new A2A.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],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AgentInterface> CreateAgentInterfaces(string[] agentUrls)
|
||||
{
|
||||
List<AgentInterface> agentInterfaces = [];
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.JsonRpc,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.HttpJson,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
return agentInterfaces;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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() => 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() => this.Products.Sum(product => product.TotalPrice()); // Total price of all products in the invoice
|
||||
}
|
||||
|
||||
public class InvoiceQuery
|
||||
{
|
||||
private readonly List<Invoice> _invoices;
|
||||
|
||||
public InvoiceQuery()
|
||||
{
|
||||
// Extended mock data with quantities and prices
|
||||
this._invoices =
|
||||
[
|
||||
new("TICKET-XYZ987", "INV789", "Contoso", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 150, 10.00m),
|
||||
new("Hats", 200, 15.00m),
|
||||
new("Glasses", 300, 5.00m)
|
||||
]),
|
||||
new("TICKET-XYZ111", "INV111", "XStore", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 2500, 12.00m),
|
||||
new("Hats", 1500, 8.00m),
|
||||
new("Glasses", 200, 20.00m)
|
||||
]),
|
||||
new("TICKET-XYZ222", "INV222", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 1200, 14.00m),
|
||||
new("Hats", 800, 7.00m),
|
||||
new("Glasses", 500, 25.00m)
|
||||
]),
|
||||
new("TICKET-XYZ333", "INV333", "Contoso", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 400, 11.00m),
|
||||
new("Hats", 600, 15.00m),
|
||||
new("Glasses", 700, 5.00m)
|
||||
]),
|
||||
new("TICKET-XYZ444", "INV444", "XStore", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 800, 10.00m),
|
||||
new("Hats", 500, 18.00m),
|
||||
new("Glasses", 300, 22.00m)
|
||||
]),
|
||||
new("TICKET-XYZ555", "INV555", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 1100, 9.00m),
|
||||
new("Hats", 900, 12.00m),
|
||||
new("Glasses", 1200, 15.00m)
|
||||
]),
|
||||
new("TICKET-XYZ666", "INV666", "Contoso", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 2500, 8.00m),
|
||||
new("Hats", 1200, 10.00m),
|
||||
new("Glasses", 1000, 6.00m)
|
||||
]),
|
||||
new("TICKET-XYZ777", "INV777", "XStore", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 1900, 13.00m),
|
||||
new("Hats", 1300, 16.00m),
|
||||
new("Glasses", 800, 19.00m)
|
||||
]),
|
||||
new("TICKET-XYZ888", "INV888", "Cymbal Direct", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
new("T-Shirts", 2200, 11.00m),
|
||||
new("Hats", 1700, 8.50m),
|
||||
new("Glasses", 600, 21.00m)
|
||||
]),
|
||||
new("TICKET-XYZ999", "INV999", "Contoso", GetRandomDateWithinLastTwoMonths(),
|
||||
[
|
||||
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.UtcNow;
|
||||
|
||||
// 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;
|
||||
int randomDays = Random.Shared.Next(0, totalDays + 1); // +1 to include the end date
|
||||
|
||||
// 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,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using A2AServer;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
string agentName = string.Empty;
|
||||
string agentType = string.Empty;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i].Equals("--agentName", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
agentName = args[++i];
|
||||
}
|
||||
else if (args[i].Equals("--agentType", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
agentType = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.AddUserSecrets<Program>()
|
||||
.Build();
|
||||
|
||||
string? apiKey = configuration["OPENAI_API_KEY"];
|
||||
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
|
||||
string? endpoint = configuration["FOUNDRY_PROJECT_ENDPOINT"];
|
||||
string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';');
|
||||
|
||||
var invoiceQueryPlugin = new InvoiceQuery();
|
||||
IList<AITool> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
|
||||
];
|
||||
|
||||
AIAgent hostA2AAgent;
|
||||
AgentCard hostA2AAgentCard;
|
||||
|
||||
if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, apiKey, "InvoiceAgent",
|
||||
"""
|
||||
You specialize in handling queries related to invoices.
|
||||
""", agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, 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."
|
||||
""", agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, 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
|
||||
""", agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// builder.Services.AddKeyedSingleton<AgentSessionStore>(hostA2AAgent.Name, new InMemoryAgentSessionStore());
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapA2AHttpJson(hostA2AAgent, "/");
|
||||
app.MapA2AJsonRpc(hostA2AAgent, "/");
|
||||
|
||||
app.MapWellKnownAgentCard(hostA2AAgentCard);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,235 @@
|
||||
# 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 a Microsoft Foundry project and then provide the project endpoint and agent 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" --agentName "<Invoice Agent Name>" --agentType "invoice" --no-build
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet run --urls "http://localhost:5001;https://localhost:5011" --agentName "<Policy Agent Name>" --agentType "policy" --no-build
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentName "<Logistics Agent Name>" --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-10.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-card.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": {
|
||||
"kind": "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 (Agent2Agent) 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-5.4-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.
|
||||
```
|
||||
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 439 KiB |
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
|
||||
// and display streaming updates including conversation/response metadata, text content, and errors.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIClient;
|
||||
|
||||
[JsonSerializable(typeof(SensorRequest))]
|
||||
[JsonSerializable(typeof(SensorResponse))]
|
||||
internal sealed partial class AGUIClientSerializerContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
|
||||
// and display streaming updates including conversation/response metadata, text content, and errors.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AGUIClient;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
// Create root command with options
|
||||
RootCommand rootCommand = new("AGUIClient");
|
||||
rootCommand.SetAction((_, ct) => HandleCommandsAsync(ct));
|
||||
|
||||
// Run the command
|
||||
return await rootCommand.Parse(args).InvokeAsync();
|
||||
}
|
||||
|
||||
private static async Task HandleCommandsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Set up the logging
|
||||
using ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
builder.SetMinimumLevel(LogLevel.Information);
|
||||
});
|
||||
ILogger logger = loggerFactory.CreateLogger("AGUIClient");
|
||||
|
||||
// Retrieve configuration settings
|
||||
IConfigurationRoot configRoot = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.Build();
|
||||
|
||||
string serverUrl = configRoot["AGUI_SERVER_URL"] ?? "http://localhost:5100";
|
||||
|
||||
logger.LogInformation("Connecting to AG-UI server at: {ServerUrl}", serverUrl);
|
||||
|
||||
// Create the AG-UI client agent
|
||||
using HttpClient httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
var changeBackground = AIFunctionFactory.Create(
|
||||
() =>
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkBlue;
|
||||
Console.WriteLine("Changing color to blue");
|
||||
},
|
||||
name: "change_background_color",
|
||||
description: "Change the console background color to dark blue."
|
||||
);
|
||||
|
||||
var readClientClimateSensors = AIFunctionFactory.Create(
|
||||
([Description("The sensors measurements to include in the response")] SensorRequest request) =>
|
||||
{
|
||||
return new SensorResponse()
|
||||
{
|
||||
Temperature = 22.5,
|
||||
Humidity = 45.0,
|
||||
AirQualityIndex = 75
|
||||
};
|
||||
},
|
||||
name: "read_client_climate_sensors",
|
||||
description: "Reads the climate sensor data from the client device.",
|
||||
serializerOptions: AGUIClientSerializerContext.Default.Options
|
||||
);
|
||||
|
||||
var chatClient = new AGUIChatClient(new(httpClient, serverUrl)
|
||||
{
|
||||
JsonSerializerOptions = AGUIClientSerializerContext.Default.Options,
|
||||
});
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "agui-client",
|
||||
description: "AG-UI Client Agent",
|
||||
tools: [changeBackground, readClientClimateSensors]);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync(cancellationToken);
|
||||
List<ChatMessage> messages = [new(ChatRole.System, "You are a helpful assistant.")];
|
||||
|
||||
// AGUIChatClient is stateless: it never surfaces a ConversationId. AG-UI continuation is expressed
|
||||
// on the wire as the same threadId plus a parentRunId equal to the previous turn's runId. We read
|
||||
// both from each turn's RUN_STARTED event and send them back on the next turn via
|
||||
// ChatOptions.RawRepresentationFactory (the pattern the AG-UI samples use).
|
||||
string? threadId = null;
|
||||
string? previousRunId = null;
|
||||
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 is ":q" or "quit")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
messages.Add(new(ChatRole.User, message));
|
||||
|
||||
// On continuation turns, carry the thread and the previous run id back to the stateless
|
||||
// client so the server resumes the same AG-UI conversation.
|
||||
AgentRunOptions? runOptions = previousRunId is null
|
||||
? null
|
||||
: new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
RawRepresentationFactory = _ => new RunAgentInput
|
||||
{
|
||||
ThreadId = threadId ?? string.Empty,
|
||||
ParentRunId = previousRunId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Call RunStreamingAsync to get streaming updates
|
||||
bool isFirstUpdate = true;
|
||||
string? currentRunId = null;
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, runOptions, cancellationToken))
|
||||
{
|
||||
// Use AsChatResponseUpdate to access ChatResponseUpdate properties
|
||||
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
|
||||
updates.Add(chatUpdate);
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id and run id are carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
if (chatUpdate.RawRepresentation is RunStartedEvent runStarted)
|
||||
{
|
||||
threadId = runStarted.ThreadId;
|
||||
currentRunId = runStarted.RunId;
|
||||
}
|
||||
|
||||
// Display run started information from the first update
|
||||
if (isFirstUpdate && threadId != null && update.ResponseId != null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {update.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
|
||||
// Display different content types with appropriate formatting
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write(textContent.Text);
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionCallContent functionCallContent:
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Function Call - Name: {functionCallContent.Name}, Arguments: {PrintArguments(functionCallContent.Arguments)}]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResultContent:
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
if (functionResultContent.Exception != null)
|
||||
{
|
||||
Console.WriteLine($"\n[Function Result - Exception: {functionResultContent.Exception}]");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"\n[Function Result - Result: {functionResultContent.Result}]");
|
||||
}
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case ErrorContent errorContent:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
string code = errorContent.AdditionalProperties?["Code"] as string ?? "Unknown";
|
||||
Console.WriteLine($"\n[Error - Code: {code}, Message: {errorContent.Message}]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.Count > 0 && !updates[^1].Contents.Any(c => c is TextContent))
|
||||
{
|
||||
var lastUpdate = updates[^1];
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"[Run Ended - Thread: {threadId}, Run: {lastUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
// Remember this run as the parent of the next turn, and drop the local message buffer:
|
||||
// continuation now travels on the wire as threadId + parentRunId, so each turn sends only
|
||||
// the new user message.
|
||||
previousRunId = currentRunId;
|
||||
messages.Clear();
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogInformation("AGUIClient operation was canceled.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not ThreadAbortException and not AccessViolationException)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while running the AGUIClient");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static string PrintArguments(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
if (arguments == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
var builder = new StringBuilder().AppendLine();
|
||||
foreach (var kvp in arguments)
|
||||
{
|
||||
builder
|
||||
.AppendLine($" Name: {kvp.Key}")
|
||||
.AppendLine($" Value: {kvp.Value}");
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# AG-UI Client
|
||||
|
||||
This is a console application that demonstrates how to connect to an AG-UI server and interact with remote agents using the AG-UI protocol.
|
||||
|
||||
## Features
|
||||
|
||||
- Connects to an AG-UI server endpoint
|
||||
- Displays streaming updates with color-coded output:
|
||||
- **Yellow**: Run started notifications
|
||||
- **Cyan**: Agent text responses (streamed)
|
||||
- **Green**: Run finished notifications
|
||||
- **Red**: Error messages (if any)
|
||||
- Interactive prompt loop for sending messages
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variable to specify the AG-UI server URL:
|
||||
|
||||
```powershell
|
||||
$env:AGUI_SERVER_URL="http://localhost:5100"
|
||||
```
|
||||
|
||||
If not set, the default is `http://localhost:5100`.
|
||||
|
||||
## Running the Client
|
||||
|
||||
1. Make sure the AG-UI server is running
|
||||
2. Run the client:
|
||||
```bash
|
||||
cd AGUIClient
|
||||
dotnet run
|
||||
```
|
||||
3. Enter your messages and observe the streaming updates
|
||||
4. Type `:q` or `quit` to exit
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
|
||||
// and display streaming updates including conversation/response metadata, text content, and errors.
|
||||
|
||||
namespace AGUIClient;
|
||||
|
||||
internal sealed class SensorRequest
|
||||
{
|
||||
public bool IncludeTemperature { get; set; } = true;
|
||||
public bool IncludeHumidity { get; set; } = true;
|
||||
public bool IncludeAirQualityIndex { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
|
||||
// and display streaming updates including conversation/response metadata, text content, and errors.
|
||||
|
||||
namespace AGUIClient;
|
||||
|
||||
internal sealed class SensorResponse
|
||||
{
|
||||
public double Temperature { get; set; }
|
||||
public double Humidity { get; set; }
|
||||
public int AirQualityIndex { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using AGUIDojoServer.AgenticUI;
|
||||
using AGUIDojoServer.BackendToolRendering;
|
||||
using AGUIDojoServer.PredictiveStateUpdates;
|
||||
using AGUIDojoServer.SharedState;
|
||||
|
||||
namespace AGUIDojoServer;
|
||||
|
||||
[JsonSerializable(typeof(WeatherInfo))]
|
||||
[JsonSerializable(typeof(Recipe))]
|
||||
[JsonSerializable(typeof(Ingredient))]
|
||||
[JsonSerializable(typeof(RecipeResponse))]
|
||||
[JsonSerializable(typeof(Plan))]
|
||||
[JsonSerializable(typeof(Step))]
|
||||
[JsonSerializable(typeof(StepStatus))]
|
||||
[JsonSerializable(typeof(StepStatus?))]
|
||||
[JsonSerializable(typeof(JsonPatchOperation))]
|
||||
[JsonSerializable(typeof(List<JsonPatchOperation>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(DocumentState))]
|
||||
internal sealed partial class AGUIDojoServerSerializerContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
internal static class AgenticPlanningTools
|
||||
{
|
||||
[Description("Create a plan with multiple steps.")]
|
||||
public static Plan CreatePlan([Description("List of step descriptions to create the plan.")] List<string> steps)
|
||||
{
|
||||
return new Plan
|
||||
{
|
||||
Steps = [.. steps.Select(s => new Step { Description = s, Status = StepStatus.Pending })]
|
||||
};
|
||||
}
|
||||
|
||||
[Description("Update a step in the plan with new description or status.")]
|
||||
public static async Task<List<JsonPatchOperation>> UpdatePlanStepAsync(
|
||||
[Description("The index of the step to update.")] int index,
|
||||
[Description("The new description for the step (optional).")] string? description = null,
|
||||
[Description("The new status for the step (optional).")] StepStatus? status = null)
|
||||
{
|
||||
var changes = new List<JsonPatchOperation>();
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/description",
|
||||
Value = description
|
||||
});
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
// Status must be lowercase to match AG-UI frontend expectations: "pending" or "completed"
|
||||
string statusValue = status.Value == StepStatus.Pending ? "pending" : "completed";
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/status",
|
||||
Value = statusValue
|
||||
});
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateAgenticUI")]
|
||||
internal sealed class AgenticUIAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public AgenticUIAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Track function calls that should trigger state events
|
||||
var trackedFunctionCalls = new Dictionary<string, FunctionCallContent>();
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Process contents: track function calls and emit state events for results
|
||||
List<AIContent> stateEventsToEmit = new();
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent callContent)
|
||||
{
|
||||
if (callContent.Name == "create_plan" || callContent.Name == "update_plan_step")
|
||||
{
|
||||
trackedFunctionCalls[callContent.CallId] = callContent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (content is FunctionResultContent resultContent)
|
||||
{
|
||||
// Check if this result matches a tracked function call
|
||||
if (trackedFunctionCalls.TryGetValue(resultContent.CallId, out var matchedCall))
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes((JsonElement)resultContent.Result!, this._jsonSerializerOptions);
|
||||
|
||||
// Determine event type based on the function name
|
||||
if (matchedCall.Name == "create_plan")
|
||||
{
|
||||
stateEventsToEmit.Add(new DataContent(bytes, "application/json"));
|
||||
}
|
||||
else if (matchedCall.Name == "update_plan_step")
|
||||
{
|
||||
stateEventsToEmit.Add(new DataContent(bytes, "application/json-patch+json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield return update;
|
||||
|
||||
yield return new AgentResponseUpdate(
|
||||
new ChatResponseUpdate(role: ChatRole.System, stateEventsToEmit)
|
||||
{
|
||||
MessageId = "delta_" + Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = update.CreatedAt,
|
||||
ResponseId = update.ResponseId,
|
||||
AuthorName = update.AuthorName,
|
||||
Role = update.Role,
|
||||
ContinuationToken = update.ContinuationToken,
|
||||
AdditionalProperties = update.AdditionalProperties,
|
||||
})
|
||||
{
|
||||
AgentId = update.AgentId
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
internal sealed class JsonPatchOperation
|
||||
{
|
||||
[JsonPropertyName("op")]
|
||||
public required string Op { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; set; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public object? Value { get; set; }
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public string? From { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
internal sealed class Plan
|
||||
{
|
||||
[JsonPropertyName("steps")]
|
||||
public List<Step> Steps { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
internal sealed class Step
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public StepStatus Status { get; set; } = StepStatus.Pending;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.AgenticUI;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<StepStatus>))]
|
||||
internal enum StepStatus
|
||||
{
|
||||
Pending,
|
||||
Completed
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.BackendToolRendering;
|
||||
|
||||
internal sealed class WeatherInfo
|
||||
{
|
||||
[JsonPropertyName("temperature")]
|
||||
public int Temperature { get; init; }
|
||||
|
||||
[JsonPropertyName("conditions")]
|
||||
public string Conditions { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("humidity")]
|
||||
public int Humidity { get; init; }
|
||||
|
||||
[JsonPropertyName("wind_speed")]
|
||||
public int WindSpeed { get; init; }
|
||||
|
||||
[JsonPropertyName("feelsLike")]
|
||||
public int FeelsLike { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using AGUIDojoServer.AgenticUI;
|
||||
using AGUIDojoServer.BackendToolRendering;
|
||||
using AGUIDojoServer.PredictiveStateUpdates;
|
||||
using AGUIDojoServer.SharedState;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
namespace AGUIDojoServer;
|
||||
|
||||
internal static class ChatClientAgentFactory
|
||||
{
|
||||
private static AzureOpenAIClient? s_azureOpenAIClient;
|
||||
private static string? s_deploymentName;
|
||||
|
||||
public static void Initialize(IConfiguration configuration)
|
||||
{
|
||||
string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
s_azureOpenAIClient = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential());
|
||||
}
|
||||
|
||||
public static ChatClientAgent CreateAgenticChat()
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsAIAgent(
|
||||
name: "AgenticChat",
|
||||
description: "A simple chat agent using Azure OpenAI");
|
||||
}
|
||||
|
||||
public static ChatClientAgent CreateBackendToolRendering()
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsAIAgent(
|
||||
name: "BackendToolRenderer",
|
||||
description: "An agent that can render backend tools using Azure OpenAI",
|
||||
tools: [AIFunctionFactory.Create(
|
||||
GetWeather,
|
||||
name: "get_weather",
|
||||
description: "Get the weather for a given location.",
|
||||
AGUIDojoServerSerializerContext.Default.Options)]);
|
||||
}
|
||||
|
||||
public static ChatClientAgent CreateHumanInTheLoop()
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsAIAgent(
|
||||
name: "HumanInTheLoopAgent",
|
||||
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
|
||||
}
|
||||
|
||||
public static ChatClientAgent CreateToolBasedGenerativeUI()
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
return chatClient.AsAIAgent(
|
||||
name: "ToolBasedGenerativeUIAgent",
|
||||
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
|
||||
}
|
||||
|
||||
public static AIAgent CreateAgenticUI(JsonSerializerOptions options)
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "AgenticUIAgent",
|
||||
Description = "An agent that generates agentic user interfaces using Azure OpenAI",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = """
|
||||
When planning use tools only, without any other messages.
|
||||
IMPORTANT:
|
||||
- Use the `create_plan` tool to set the initial state of the steps
|
||||
- Use the `update_plan_step` tool to update the status of each step
|
||||
- Do NOT repeat the plan or summarise it in a message
|
||||
- Do NOT confirm the creation or updates in a message
|
||||
- Do NOT ask the user for additional information or next steps
|
||||
- Do NOT leave a plan hanging, always complete the plan via `update_plan_step` if one is ongoing.
|
||||
- Continue calling update_plan_step until all steps are marked as completed.
|
||||
|
||||
Only one plan can be active at a time, so do not call the `create_plan` tool
|
||||
again until all the steps in current plan are completed.
|
||||
""",
|
||||
Tools = [
|
||||
AIFunctionFactory.Create(
|
||||
AgenticPlanningTools.CreatePlan,
|
||||
name: "create_plan",
|
||||
description: "Create a plan with multiple steps.",
|
||||
AGUIDojoServerSerializerContext.Default.Options),
|
||||
AIFunctionFactory.Create(
|
||||
AgenticPlanningTools.UpdatePlanStepAsync,
|
||||
name: "update_plan_step",
|
||||
description: "Update a step in the plan with new description or status.",
|
||||
AGUIDojoServerSerializerContext.Default.Options)
|
||||
],
|
||||
AllowMultipleToolCalls = false
|
||||
}
|
||||
});
|
||||
|
||||
return new AgenticUIAgent(baseAgent, options);
|
||||
}
|
||||
|
||||
public static AIAgent CreateSharedState(JsonSerializerOptions options)
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
var baseAgent = chatClient.AsAIAgent(
|
||||
name: "SharedStateAgent",
|
||||
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
|
||||
|
||||
return new SharedStateAgent(baseAgent, options);
|
||||
}
|
||||
|
||||
public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options)
|
||||
{
|
||||
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
|
||||
|
||||
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "PredictiveStateUpdatesAgent",
|
||||
Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = """
|
||||
You are a document editor assistant. When asked to write or edit content:
|
||||
|
||||
IMPORTANT:
|
||||
- Use the `write_document` tool with the full document text in Markdown format
|
||||
- Format the document extensively so it's easy to read
|
||||
- You can use all kinds of markdown (headings, lists, bold, etc.)
|
||||
- However, do NOT use italic or strike-through formatting
|
||||
- You MUST write the full document, even when changing only a few words
|
||||
- When making edits to the document, try to make them minimal - do not change every word
|
||||
- Keep stories SHORT!
|
||||
- After you are done writing the document you MUST call a confirm_changes tool after you call write_document
|
||||
|
||||
After the user confirms the changes, provide a brief summary of what you wrote.
|
||||
""",
|
||||
Tools = [
|
||||
AIFunctionFactory.Create(
|
||||
WriteDocument,
|
||||
name: "write_document",
|
||||
description: "Write a document. Use markdown formatting to format the document.",
|
||||
AGUIDojoServerSerializerContext.Default.Options)
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return new PredictiveStateUpdatesAgent(baseAgent, options);
|
||||
}
|
||||
|
||||
[Description("Get the weather for a given location.")]
|
||||
private static WeatherInfo GetWeather([Description("The location to get the weather for.")] string location) => new()
|
||||
{
|
||||
Temperature = 20,
|
||||
Conditions = "sunny",
|
||||
Humidity = 50,
|
||||
WindSpeed = 10,
|
||||
FeelsLike = 25
|
||||
};
|
||||
|
||||
[Description("Write a document in markdown format.")]
|
||||
private static string WriteDocument([Description("The document content to write.")] string document)
|
||||
{
|
||||
// Simply return success - the document is tracked via state updates
|
||||
return "Document written successfully";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.PredictiveStateUpdates;
|
||||
|
||||
internal sealed class DocumentState
|
||||
{
|
||||
[JsonPropertyName("document")]
|
||||
public string Document { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoServer.PredictiveStateUpdates;
|
||||
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreatePredictiveStateUpdates")]
|
||||
internal sealed class PredictiveStateUpdatesAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private const int ChunkSize = 10; // Characters per chunk for streaming effect
|
||||
|
||||
public PredictiveStateUpdatesAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Track the last emitted document state to avoid duplicates
|
||||
string? lastEmittedDocument = null;
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Check if we're seeing a write_document tool call and emit predictive state
|
||||
bool hasToolCall = false;
|
||||
string? documentContent = null;
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent callContent && callContent.Name == "write_document")
|
||||
{
|
||||
hasToolCall = true;
|
||||
// Try to extract the document argument directly from the dictionary
|
||||
if (callContent.Arguments?.TryGetValue("document", out var documentValue) == true)
|
||||
{
|
||||
documentContent = documentValue?.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always yield the original update first
|
||||
yield return update;
|
||||
|
||||
// If we got a complete tool call with document content, "fake" stream it in chunks
|
||||
if (hasToolCall && documentContent != null && documentContent != lastEmittedDocument)
|
||||
{
|
||||
// Chunk the document content and emit progressive state updates
|
||||
int startIndex = 0;
|
||||
if (lastEmittedDocument != null && documentContent.StartsWith(lastEmittedDocument, StringComparison.Ordinal))
|
||||
{
|
||||
// Only stream the new portion that was added
|
||||
startIndex = lastEmittedDocument.Length;
|
||||
}
|
||||
|
||||
// Stream the document in chunks
|
||||
for (int i = startIndex; i < documentContent.Length; i += ChunkSize)
|
||||
{
|
||||
int length = Math.Min(ChunkSize, documentContent.Length - i);
|
||||
string chunk = documentContent.Substring(0, i + length);
|
||||
|
||||
// Prepare predictive state update as DataContent
|
||||
var stateUpdate = new DocumentState { Document = chunk };
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateUpdate,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(DocumentState)));
|
||||
|
||||
yield return new AgentResponseUpdate(
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, [new DataContent(stateBytes, "application/json")])
|
||||
{
|
||||
MessageId = "snapshot" + Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = update.CreatedAt,
|
||||
ResponseId = update.ResponseId,
|
||||
AdditionalProperties = update.AdditionalProperties,
|
||||
AuthorName = update.AuthorName,
|
||||
ContinuationToken = update.ContinuationToken,
|
||||
})
|
||||
{
|
||||
AgentId = update.AgentId
|
||||
};
|
||||
|
||||
// Small delay to simulate streaming
|
||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lastEmittedDocument = documentContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUIDojoServer;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.AspNetCore.HttpLogging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddHttpLogging(logging =>
|
||||
{
|
||||
logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody
|
||||
| HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody;
|
||||
logging.RequestBodyLogLimit = int.MaxValue;
|
||||
logging.ResponseBodyLogLimit = int.MaxValue;
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
// Initialize the factory
|
||||
ChatClientAgentFactory.Initialize(app.Configuration);
|
||||
|
||||
// Map the AG-UI agent endpoints for different scenarios
|
||||
app.MapAGUIServer("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat());
|
||||
|
||||
app.MapAGUIServer("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering());
|
||||
|
||||
app.MapAGUIServer("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop());
|
||||
|
||||
app.MapAGUIServer("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI());
|
||||
|
||||
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
|
||||
app.MapAGUIServer("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI(jsonOptions.Value.SerializerOptions));
|
||||
|
||||
app.MapAGUIServer("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions));
|
||||
|
||||
app.MapAGUIServer("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions));
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"AGUIDojoServer": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:5018"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.SharedState;
|
||||
|
||||
internal sealed class Ingredient
|
||||
{
|
||||
[JsonPropertyName("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public string Amount { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.SharedState;
|
||||
|
||||
internal sealed class Recipe
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("skill_level")]
|
||||
public string SkillLevel { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cooking_time")]
|
||||
public string CookingTime { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("special_preferences")]
|
||||
public List<string> SpecialPreferences { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ingredients")]
|
||||
public List<Ingredient> Ingredients { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("instructions")]
|
||||
public List<string> Instructions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoServer.SharedState;
|
||||
|
||||
#pragma warning disable CA1812 // Used for the JsonSchema response format
|
||||
internal sealed class RecipeResponse
|
||||
#pragma warning restore CA1812
|
||||
{
|
||||
[JsonPropertyName("recipe")]
|
||||
public Recipe Recipe { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Server;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoServer.SharedState;
|
||||
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateSharedState")]
|
||||
internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions ||
|
||||
!chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) ||
|
||||
agentInput.State is not { ValueKind: not JsonValueKind.Undefined } state)
|
||||
{
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
var firstRunOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatOptions = chatRunOptions.ChatOptions.Clone(),
|
||||
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
|
||||
ContinuationToken = chatRunOptions.ContinuationToken,
|
||||
ChatClientFactory = chatRunOptions.ChatClientFactory,
|
||||
};
|
||||
|
||||
// Configure JSON schema response format for structured state output
|
||||
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<RecipeResponse>(
|
||||
schemaName: "RecipeResponse",
|
||||
schemaDescription: "A response containing a recipe with title, skill level, cooking time, preferences, ingredients, and instructions");
|
||||
|
||||
ChatMessage stateUpdateMessage = new(
|
||||
ChatRole.System,
|
||||
[
|
||||
new TextContent("Here is the current state in JSON format:"),
|
||||
new TextContent(state.GetRawText()),
|
||||
new TextContent("The new state is:")
|
||||
]);
|
||||
|
||||
var firstRunMessages = messages.Append(stateUpdateMessage);
|
||||
|
||||
var allUpdates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, session, firstRunOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
allUpdates.Add(update);
|
||||
|
||||
// Yield all non-text updates (tool calls, etc.)
|
||||
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
|
||||
if (hasNonTextContent)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
var response = allUpdates.ToAgentResponse();
|
||||
|
||||
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
|
||||
{
|
||||
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateSnapshot,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new DataContent(stateBytes, "application/json")]
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var secondRunMessages = messages.Concat(response.Messages).Append(
|
||||
new ChatMessage(
|
||||
ChatRole.System,
|
||||
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? result = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
structuredOutput = result;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
structuredOutput = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
@host = http://localhost:5100
|
||||
|
||||
### Send a message to the AG-UI agent
|
||||
POST {{host}}/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"threadId": "thread_123",
|
||||
"runId": "run_456",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
],
|
||||
"context": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIServer;
|
||||
|
||||
[JsonSerializable(typeof(ServerWeatherForecastRequest))]
|
||||
[JsonSerializable(typeof(ServerWeatherForecastResponse))]
|
||||
internal sealed partial class AGUIServerSerializerContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using AGUIServer;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
const string AgentName = "AGUIAssistant";
|
||||
|
||||
// Create the AI agent with tools
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(
|
||||
name: AgentName,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(
|
||||
() => DateTimeOffset.UtcNow,
|
||||
name: "get_current_time",
|
||||
description: "Get the current UTC time."
|
||||
),
|
||||
AIFunctionFactory.Create(
|
||||
([Description("The weather forecast request")]ServerWeatherForecastRequest request) => {
|
||||
return new ServerWeatherForecastResponse()
|
||||
{
|
||||
Summary = "Sunny",
|
||||
TemperatureC = 25,
|
||||
Date = request.Date
|
||||
};
|
||||
},
|
||||
name: "get_server_weather_forecast",
|
||||
description: "Gets the forecast for a specific location and date",
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
.AddAIAgent(AgentName, (_, _) => agent)
|
||||
.WithInMemorySessionStore();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUIServer(AgentName, "/");
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"AGUIServer": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:5100;https://localhost:5101"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIServer;
|
||||
|
||||
internal sealed class ServerWeatherForecastRequest
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Location { get; set; } = "Seattle";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIServer;
|
||||
|
||||
internal sealed class ServerWeatherForecastResponse
|
||||
{
|
||||
public string Summary { get; set; } = "";
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public DateTime Date { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# AG-UI Client and Server Sample
|
||||
|
||||
This sample demonstrates how to use the AG-UI (Agent UI) protocol to enable communication between a client application and a remote agent server. The AG-UI protocol provides a standardized way for clients to interact with AI agents.
|
||||
|
||||
## Overview
|
||||
|
||||
The demonstration has two components:
|
||||
|
||||
1. **AGUIServer** - An ASP.NET Core web server that hosts an AI agent and exposes it via the AG-UI protocol
|
||||
2. **AGUIClient** - A console application that connects to the AG-UI server and displays streaming updates
|
||||
|
||||
> **Warning**
|
||||
> The AG-UI protocol is still under development and changing.
|
||||
> We will try to keep these samples updated as the protocol evolves.
|
||||
|
||||
## Configuring Environment Variables
|
||||
|
||||
Configure the required Azure OpenAI environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="<<your-model-endpoint>>"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
```
|
||||
|
||||
> **Note:** This sample uses `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, Visual Studio, or environment variables).
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Step 1: Start the AG-UI Server
|
||||
|
||||
```bash
|
||||
cd AGUIServer
|
||||
dotnet build
|
||||
dotnet run --urls "http://localhost:5100"
|
||||
```
|
||||
|
||||
The server will start and listen on `http://localhost:5100`.
|
||||
|
||||
### Step 2: Testing with the REST Client (Optional)
|
||||
|
||||
Before running the client, you can test the server using the included `.http` file:
|
||||
|
||||
1. Open [./AGUIServer/AGUIServer.http](./AGUIServer/AGUIServer.http) in Visual Studio or VS Code with the REST Client extension
|
||||
2. Send a test request to verify the server is working
|
||||
3. Observe the server-sent events stream in the response
|
||||
|
||||
Sample request:
|
||||
```http
|
||||
POST http://localhost:5100/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"threadId": "thread_123",
|
||||
"runId": "run_456",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
],
|
||||
"context": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Run the AG-UI Client
|
||||
|
||||
In a new terminal window:
|
||||
|
||||
```bash
|
||||
cd AGUIClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Optionally, configure a different server URL:
|
||||
|
||||
```powershell
|
||||
$env:AGUI_SERVER_URL="http://localhost:5100"
|
||||
```
|
||||
|
||||
### Step 4: Interact with the Agent
|
||||
|
||||
1. The client will connect to the AG-UI server
|
||||
2. Enter your message at the prompt
|
||||
3. Observe the streaming updates with color-coded output:
|
||||
- **Yellow**: Run started notification showing thread and run IDs
|
||||
- **Cyan**: Agent's text response (streamed character by character)
|
||||
- **Green**: Run finished notification
|
||||
- **Red**: Error messages (if any occur)
|
||||
4. Type `:q` or `quit` to exit
|
||||
|
||||
## Sample Output
|
||||
|
||||
```
|
||||
AGUIClient> dotnet run
|
||||
info: AGUIClient[0]
|
||||
Connecting to AG-UI server at: http://localhost:5100
|
||||
|
||||
User (:q or quit to exit): What is the capital of France?
|
||||
|
||||
[Run Started - Thread: thread_abc123, Run: run_xyz789]
|
||||
The capital of France is Paris. It is known for its rich history, culture, and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
|
||||
[Run Finished - Thread: thread_abc123, Run: run_xyz789]
|
||||
|
||||
User (:q or quit to exit): Tell me a fun fact about space
|
||||
|
||||
[Run Started - Thread: thread_abc123, Run: run_def456]
|
||||
Here's a fun fact: A day on Venus is longer than its year! Venus takes about 243 Earth days to rotate once on its axis, but only about 225 Earth days to orbit the Sun.
|
||||
[Run Finished - Thread: thread_abc123, Run: run_def456]
|
||||
|
||||
User (:q or quit to exit): :q
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Server Side
|
||||
|
||||
The `AGUIServer` uses the `MapAGUIServer` extension method to expose an agent through the AG-UI protocol:
|
||||
|
||||
```csharp
|
||||
AIAgent agent = new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
.AsAIAgent(
|
||||
instructions: "You are a helpful assistant.",
|
||||
name: "AGUIAssistant");
|
||||
|
||||
app.MapAGUIServer("/", agent);
|
||||
```
|
||||
|
||||
This automatically handles:
|
||||
- HTTP POST requests with message payloads
|
||||
- Converting agent responses to AG-UI event streams
|
||||
- Server-sent events (SSE) formatting
|
||||
- Thread and run management
|
||||
|
||||
### Client Side
|
||||
|
||||
The `AGUIClient` uses the `AGUIChatClient` to connect to the remote server:
|
||||
|
||||
```csharp
|
||||
using HttpClient httpClient = new();
|
||||
var chatClient = new AGUIChatClient(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
instructions: null,
|
||||
name: "agui-client",
|
||||
description: "AG-UI Client Agent",
|
||||
tools: []);
|
||||
|
||||
bool isFirstUpdate = true;
|
||||
AgentResponseUpdate? currentUpdate = null;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, thread))
|
||||
{
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread id is
|
||||
// carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
if (update.AsChatResponseUpdate().RawRepresentation is RunStartedEvent runStarted)
|
||||
{
|
||||
threadId = runStarted.ThreadId;
|
||||
}
|
||||
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {update.ResponseId}]");
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
|
||||
currentUpdate = update;
|
||||
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
// Display streaming text
|
||||
Console.Write(textContent.Text);
|
||||
break;
|
||||
case ErrorContent errorContent:
|
||||
// Display error notification
|
||||
Console.WriteLine($"[Error: {errorContent.Message}]");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last update indicates run finished
|
||||
if (currentUpdate != null)
|
||||
{
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}, Run: {currentUpdate.ResponseId}]");
|
||||
}
|
||||
```
|
||||
|
||||
The `RunStreamingAsync` method:
|
||||
1. Sends messages to the server via HTTP POST
|
||||
2. Receives server-sent events (SSE) stream
|
||||
3. Parses events into `AgentResponseUpdate` objects
|
||||
4. Yields updates as they arrive for real-time display
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Thread**: Represents a conversation context that persists across multiple runs. `AGUIChatClient` is stateless and does not surface a `ConversationId`; the thread id is read from the `RUN_STARTED`/`RUN_FINISHED` event's raw representation (`RunStartedEvent.ThreadId`). Continuation is driven by resending the full message history (and, to branch from a prior run, setting `RunAgentInput.ThreadId`/`ParentRunId` via `ChatOptions.RawRepresentationFactory`).
|
||||
- **Run**: A single execution of the agent for a given set of messages (identified by `ResponseId` property)
|
||||
- **AgentResponseUpdate**: Contains the response data with:
|
||||
- `ResponseId`: The unique run identifier
|
||||
- `RawRepresentation`: The underlying AG-UI event (e.g. `RunStartedEvent`), which carries wire-level fields such as the thread id
|
||||
- `Contents`: Collection of content items (TextContent, ErrorContent, etc.)
|
||||
- **Run Lifecycle**:
|
||||
- The **first** `AgentResponseUpdate` in a run indicates the run has started
|
||||
- Subsequent updates contain streaming content as the agent processes
|
||||
- The **last** `AgentResponseUpdate` in a run indicates the run has finished
|
||||
- If an error occurs, the update will contain `ErrorContent`
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="AGUIWebChatClient.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="@renderMode" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="@renderMode" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
|
||||
@@ -0,0 +1,89 @@
|
||||
/* Used under CC0 license */
|
||||
|
||||
.lds-ellipsis {
|
||||
color: #666;
|
||||
animation: fade-in 1s;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lds-ellipsis,
|
||||
.lds-ellipsis div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lds-ellipsis {
|
||||
margin: auto;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 33.33333px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@page "/"
|
||||
@using System.ComponentModel
|
||||
@inject IChatClient ChatClient
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Chat</PageTitle>
|
||||
|
||||
<ChatHeader OnNewChat="@ResetConversationAsync" />
|
||||
|
||||
<ChatMessageList Messages="@messages" InProgressMessage="@currentResponseMessage">
|
||||
<NoMessagesContent>
|
||||
<div>Ask the assistant a question to start a conversation.</div>
|
||||
</NoMessagesContent>
|
||||
</ChatMessageList>
|
||||
<div class="chat-container">
|
||||
<ChatSuggestions OnSelected="@AddUserMessageAsync" @ref="@chatSuggestions" />
|
||||
<ChatInput OnSend="@AddUserMessageAsync" @ref="@chatInput" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const string SystemPrompt = @"
|
||||
You are a helpful assistant.
|
||||
";
|
||||
|
||||
private int statefulMessageCount;
|
||||
private readonly ChatOptions chatOptions = new();
|
||||
private readonly List<ChatMessage> messages = new();
|
||||
private CancellationTokenSource? currentResponseCancellation;
|
||||
private ChatMessage? currentResponseMessage;
|
||||
private ChatInput? chatInput;
|
||||
private ChatSuggestions? chatSuggestions;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
statefulMessageCount = 0;
|
||||
messages.Add(new(ChatRole.System, SystemPrompt));
|
||||
}
|
||||
|
||||
private async Task AddUserMessageAsync(ChatMessage userMessage)
|
||||
{
|
||||
CancelAnyCurrentResponse();
|
||||
|
||||
// Add the user message to the conversation
|
||||
messages.Add(userMessage);
|
||||
chatSuggestions?.Clear();
|
||||
await chatInput!.FocusAsync();
|
||||
|
||||
// Stream and display a new response from the IChatClient
|
||||
var responseText = new TextContent("");
|
||||
currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]);
|
||||
StateHasChanged();
|
||||
currentResponseCancellation = new();
|
||||
await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token))
|
||||
{
|
||||
messages.AddMessages(update, filter: c => c is not TextContent);
|
||||
responseText.Text += update.Text;
|
||||
chatOptions.ConversationId = update.ConversationId;
|
||||
ChatMessageItem.NotifyChanged(currentResponseMessage);
|
||||
}
|
||||
|
||||
// Store the final response in the conversation, and begin getting suggestions
|
||||
messages.Add(currentResponseMessage!);
|
||||
statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0;
|
||||
currentResponseMessage = null;
|
||||
chatSuggestions?.Update(messages);
|
||||
}
|
||||
|
||||
private void CancelAnyCurrentResponse()
|
||||
{
|
||||
// If a response was cancelled while streaming, include it in the conversation so it's not lost
|
||||
if (currentResponseMessage is not null)
|
||||
{
|
||||
messages.Add(currentResponseMessage);
|
||||
}
|
||||
|
||||
currentResponseCancellation?.Cancel();
|
||||
currentResponseMessage = null;
|
||||
}
|
||||
|
||||
private async Task ResetConversationAsync()
|
||||
{
|
||||
CancelAnyCurrentResponse();
|
||||
messages.Clear();
|
||||
messages.Add(new(ChatRole.System, SystemPrompt));
|
||||
chatOptions.ConversationId = null;
|
||||
statefulMessageCount = 0;
|
||||
chatSuggestions?.Clear();
|
||||
await chatInput!.FocusAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> currentResponseCancellation?.Cancel();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.chat-container {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-top-width: 1px;
|
||||
background-color: #F3F4F6;
|
||||
border-color: #E5E7EB;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@using System.Web
|
||||
@if (!string.IsNullOrWhiteSpace(viewerUrl))
|
||||
{
|
||||
<a href="@viewerUrl" target="_blank" class="citation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
<div class="citation-content">
|
||||
<div class="citation-file">@File</div>
|
||||
<div>@Quote</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required string File { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int? PageNumber { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public required string Quote { get; set; }
|
||||
|
||||
private string? viewerUrl;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
viewerUrl = null;
|
||||
|
||||
// If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here
|
||||
if (File.EndsWith(".pdf"))
|
||||
{
|
||||
var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\'');
|
||||
viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.citation {
|
||||
display: inline-flex;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
margin-right: 1rem;
|
||||
border-bottom: 2px solid #a770de;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.citation[href]:hover {
|
||||
outline: 1px solid #865cb1;
|
||||
}
|
||||
|
||||
.citation svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.citation:active {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.citation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.citation-file {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="chat-header-container main-background-gradient">
|
||||
<div class="chat-header-controls page-width">
|
||||
<button class="btn-default" @onclick="@OnNewChat">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="new-chat-icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="page-width">AGUI WebChat</h1>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback OnNewChat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.chat-header-container {
|
||||
top: 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.chat-header-controls {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.new-chat-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: rgb(55, 65, 81);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat-header-container {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<EditForm Model="@this" OnValidSubmit="@SendMessageAsync">
|
||||
<label class="input-box page-width">
|
||||
<textarea @ref="@textArea" @bind="@messageText" placeholder="Type your message..." rows="1"></textarea>
|
||||
|
||||
<div class="tools">
|
||||
<button type="submit" title="Send" class="send-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="tool-icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private ElementReference textArea;
|
||||
private string? messageText;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChatMessage> OnSend { get; set; }
|
||||
|
||||
public ValueTask FocusAsync()
|
||||
=> textArea.FocusAsync();
|
||||
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
if (messageText is { Length: > 0 } text)
|
||||
{
|
||||
messageText = null;
|
||||
await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Pages/Chat/ChatInput.razor.js");
|
||||
await module.InvokeVoidAsync("init", textArea);
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.input-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border: 1px solid rgb(229, 231, 235);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.input-box:focus-within {
|
||||
outline: 2px solid #4152d5;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
textarea:placeholder-shown + .tools {
|
||||
--send-button-color: #aaa;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
color: var(--send-button-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.attach {
|
||||
background-color: white;
|
||||
border-style: dashed;
|
||||
color: #888;
|
||||
border-color: #888;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.attach:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: black;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export function init(elem) {
|
||||
elem.focus();
|
||||
|
||||
// Auto-resize whenever the user types or if the value is set programmatically
|
||||
elem.addEventListener('input', () => resizeToFit(elem));
|
||||
afterPropertyWritten(elem, 'value', () => resizeToFit(elem));
|
||||
|
||||
// Auto-submit the form on 'enter' keypress
|
||||
elem.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
elem.dispatchEvent(new CustomEvent('change', { bubbles: true }));
|
||||
elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resizeToFit(elem) {
|
||||
const lineHeight = parseFloat(getComputedStyle(elem).lineHeight);
|
||||
|
||||
elem.rows = 1;
|
||||
const numLines = Math.ceil(elem.scrollHeight / lineHeight);
|
||||
elem.rows = Math.min(5, Math.max(1, numLines));
|
||||
}
|
||||
|
||||
function afterPropertyWritten(target, propName, callback) {
|
||||
const descriptor = getPropertyDescriptor(target, propName);
|
||||
Object.defineProperty(target, propName, {
|
||||
get: function () {
|
||||
return descriptor.get.apply(this, arguments);
|
||||
},
|
||||
set: function () {
|
||||
const result = descriptor.set.apply(this, arguments);
|
||||
callback();
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getPropertyDescriptor(target, propertyName) {
|
||||
return Object.getOwnPropertyDescriptor(target, propertyName)
|
||||
|| getPropertyDescriptor(Object.getPrototypeOf(target), propertyName);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@using System.Runtime.CompilerServices
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.Linq
|
||||
|
||||
@if (Message.Role == ChatRole.User)
|
||||
{
|
||||
<div class="user-message">
|
||||
@Message.Text
|
||||
</div>
|
||||
}
|
||||
else if (Message.Role == ChatRole.Assistant)
|
||||
{
|
||||
foreach (var content in Message.Contents)
|
||||
{
|
||||
if (content is TextContent { Text: { Length: > 0 } text })
|
||||
{
|
||||
<div class="assistant-message">
|
||||
<div>
|
||||
<div class="assistant-message-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assistant-message-header">Assistant</div>
|
||||
<div class="assistant-message-text">
|
||||
<div>@((MarkupString)text)</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true)
|
||||
{
|
||||
<div class="assistant-search">
|
||||
<div class="assistant-search-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="assistant-search-content">
|
||||
Searching:
|
||||
<span class="assistant-search-phrase">@searchPhrase</span>
|
||||
@if (fcc.Arguments?.TryGetValue("filenameFilter", out var filenameObj) is true && filenameObj is string filename && !string.IsNullOrEmpty(filename))
|
||||
{
|
||||
<text> in </text><span class="assistant-search-phrase">@filename</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly ConditionalWeakTable<ChatMessage, ChatMessageItem> SubscribersLookup = new();
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public required ChatMessage Message { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool InProgress { get; set;}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SubscribersLookup.AddOrUpdate(Message, this);
|
||||
}
|
||||
|
||||
public static void NotifyChanged(ChatMessage source)
|
||||
{
|
||||
if (SubscribersLookup.TryGetValue(source, out var subscriber))
|
||||
{
|
||||
subscriber.StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
.user-message {
|
||||
background: rgb(182 215 232);
|
||||
align-self: flex-end;
|
||||
min-width: 25%;
|
||||
max-width: calc(100% - 5rem);
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #1F2937;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-message, .assistant-search {
|
||||
display: grid;
|
||||
grid-template-rows: min-content;
|
||||
grid-template-columns: 2rem minmax(0, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.assistant-message-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-message-text {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
.assistant-message-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: #ffffff;
|
||||
background: #9b72ce;
|
||||
}
|
||||
|
||||
.assistant-message-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.assistant-search {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.assistant-search-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.assistant-search-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.assistant-search-content {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.assistant-search-phrase {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="message-list-container">
|
||||
<chat-messages class="page-width message-list" in-progress="@(InProgressMessage is not null)">
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
<ChatMessageItem @key="@message" Message="@message" />
|
||||
}
|
||||
|
||||
@if (InProgressMessage is not null)
|
||||
{
|
||||
<ChatMessageItem Message="@InProgressMessage" InProgress="true" />
|
||||
<LoadingSpinner />
|
||||
}
|
||||
else if (IsEmpty)
|
||||
{
|
||||
<div class="no-messages">@NoMessagesContent</div>
|
||||
}
|
||||
</chat-messages>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required IEnumerable<ChatMessage> Messages { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public ChatMessage? InProgressMessage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? NoMessagesContent { get; set; }
|
||||
|
||||
private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text));
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Activates the auto-scrolling behavior
|
||||
await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.message-list-container {
|
||||
margin: 2rem 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
margin-top: calc(40vh - 18rem);
|
||||
}
|
||||
|
||||
chat-messages > ::deep div:last-of-type {
|
||||
/* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The following logic provides auto-scroll behavior for the chat messages list.
|
||||
// If you don't want that behavior, you can simply not load this module.
|
||||
|
||||
window.customElements.define('chat-messages', class ChatMessages extends HTMLElement {
|
||||
static _isFirstAutoScroll = true;
|
||||
|
||||
connectedCallback() {
|
||||
this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations));
|
||||
this._observer.observe(this, { childList: true, attributes: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
_scheduleAutoScroll(mutations) {
|
||||
// Debounce the calls in case multiple DOM updates occur together
|
||||
cancelAnimationFrame(this._nextAutoScroll);
|
||||
this._nextAutoScroll = requestAnimationFrame(() => {
|
||||
const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message')));
|
||||
const elem = this.lastElementChild;
|
||||
if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) {
|
||||
elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' });
|
||||
ChatMessages._isFirstAutoScroll = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_elemIsNearScrollBoundary(elem, threshold) {
|
||||
const maxScrollPos = document.body.scrollHeight - window.innerHeight;
|
||||
const remainingScrollDistance = maxScrollPos - window.scrollY;
|
||||
return remainingScrollDistance < elem.offsetHeight + threshold;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
@inject IChatClient ChatClient
|
||||
|
||||
@if (suggestions is not null)
|
||||
{
|
||||
<div class="page-width suggestions">
|
||||
@foreach (var suggestion in suggestions)
|
||||
{
|
||||
<button class="btn-subtle" @onclick="@(() => AddSuggestionAsync(suggestion))">
|
||||
@suggestion
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static string Prompt = @"
|
||||
Suggest up to 3 follow-up questions that I could ask you to help me complete my task.
|
||||
Each suggestion must be a complete sentence, maximum 6 words.
|
||||
Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message,
|
||||
for example 'How do I do that?' or 'Explain ...'.
|
||||
If there are no suggestions, reply with an empty list.
|
||||
";
|
||||
|
||||
private string[]? suggestions;
|
||||
private CancellationTokenSource? cancellation;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChatMessage> OnSelected { get; set; }
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
suggestions = null;
|
||||
cancellation?.Cancel();
|
||||
}
|
||||
|
||||
public void Update(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
// Runs in the background and handles its own cancellation/errors
|
||||
_ = UpdateSuggestionsAsync(messages);
|
||||
}
|
||||
|
||||
private async Task UpdateSuggestionsAsync(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
cancellation?.Cancel();
|
||||
cancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await ChatClient.GetResponseAsync<string[]>(
|
||||
[.. ReduceMessages(messages), new(ChatRole.User, Prompt)],
|
||||
cancellationToken: cancellation.Token);
|
||||
if (!response.TryGetResult(out suggestions))
|
||||
{
|
||||
suggestions = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await DispatchExceptionAsync(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSuggestionAsync(string text)
|
||||
{
|
||||
await OnSelected.InvokeAsync(new(ChatRole.User, text));
|
||||
}
|
||||
|
||||
private IEnumerable<ChatMessage> ReduceMessages(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
// Get any leading system messages, plus up to 5 user/assistant messages
|
||||
// This should be enough context to generate suggestions without unnecessarily resending entire conversations when long
|
||||
var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System);
|
||||
var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5);
|
||||
return systemMessages.Concat(otherMessages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.suggestions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,12 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using AGUIWebChatClient
|
||||
@using AGUIWebChatClient.Components
|
||||
@using AGUIWebChatClient.Components.Layout
|
||||
@using Microsoft.Extensions.AI
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUI.Client;
|
||||
using AGUIWebChatClient.Components;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
string serverUrl = builder.Configuration["AGUI_SERVER_URL"] ?? "http://localhost:5100";
|
||||
|
||||
builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl));
|
||||
|
||||
builder.Services.AddChatClient(sp => new AGUIChatClient(new(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("aguiserver"), "ag-ui")));
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAntiforgery();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"AGUI_SERVER_URL": "http://localhost:5100"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
html {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
html, .main-background-gradient {
|
||||
background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
html::after {
|
||||
content: '';
|
||||
background-image: linear-gradient(to right, #3a4ed5, #3acfd5 15%, #d53abf 85%, red);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
display: flex;
|
||||
padding: 0.25rem 0.75rem;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid #9CA3AF;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 600;
|
||||
background-color: #D1D5DB;
|
||||
}
|
||||
|
||||
.btn-default:hover {
|
||||
background-color: #E5E7EB;
|
||||
}
|
||||
|
||||
.btn-subtle {
|
||||
display: flex;
|
||||
padding: 0.25rem 0.75rem;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid #D1D5DB;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-subtle:hover {
|
||||
border-color: #93C5FD;
|
||||
background-color: #DBEAFE;
|
||||
}
|
||||
|
||||
.page-width {
|
||||
max-width: 1024px;
|
||||
margin: auto;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,185 @@
|
||||
# AGUI WebChat Sample
|
||||
|
||||
This sample demonstrates a Blazor-based web chat application using the AG-UI protocol to communicate with an AI agent server.
|
||||
|
||||
The sample consists of two projects:
|
||||
|
||||
1. **Server** - An ASP.NET Core server that hosts a simple chat agent using the AG-UI protocol
|
||||
2. **Client** - A Blazor Server application with a rich chat UI for interacting with the agent
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Azure OpenAI Configuration
|
||||
|
||||
The server requires Azure OpenAI credentials. Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" # e.g., "gpt-5.4-mini"
|
||||
```
|
||||
|
||||
The server uses `DefaultAzureCredential` for authentication. Ensure you are logged in using one of the following methods:
|
||||
|
||||
- Azure CLI: `az login`
|
||||
- Azure PowerShell: `Connect-AzAccount`
|
||||
- Visual Studio or VS Code with Azure extensions
|
||||
- Environment variables with service principal credentials
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Step 1: Start the Server
|
||||
|
||||
Open a terminal and navigate to the Server directory:
|
||||
|
||||
```powershell
|
||||
cd Server
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:5100` and expose the AG-UI endpoint at `/ag-ui`.
|
||||
|
||||
### Step 2: Start the Client
|
||||
|
||||
Open a new terminal and navigate to the Client directory:
|
||||
|
||||
```powershell
|
||||
cd Client
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The client will start on `http://localhost:5000`. Open your browser and navigate to `http://localhost:5000` to access the chat interface.
|
||||
|
||||
### Step 3: Chat with the Agent
|
||||
|
||||
Type your message in the text box at the bottom of the page and press Enter or click the send button. The assistant will respond with streaming text that appears in real-time.
|
||||
|
||||
Features:
|
||||
- **Streaming responses**: Watch the assistant's response appear word by word
|
||||
- **Conversation suggestions**: The assistant may offer follow-up questions after responding
|
||||
- **New chat**: Click the "New chat" button to start a fresh conversation
|
||||
- **Auto-scrolling**: The chat automatically scrolls to show new messages
|
||||
|
||||
## How It Works
|
||||
|
||||
### Server (AG-UI Host)
|
||||
|
||||
The server (`Server/Program.cs`) creates a simple chat agent:
|
||||
|
||||
```csharp
|
||||
// Create Azure OpenAI client
|
||||
AzureOpenAIClient azureOpenAIClient = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
|
||||
|
||||
// Create AI agent
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
// Map AG-UI endpoint
|
||||
app.MapAGUIServer("/ag-ui", agent);
|
||||
```
|
||||
|
||||
The server exposes the agent via the AG-UI protocol at `http://localhost:5100/ag-ui`.
|
||||
|
||||
### Client (Blazor Web App)
|
||||
|
||||
The client (`Client/Program.cs`) configures an `AGUIChatClient` to connect to the server:
|
||||
|
||||
```csharp
|
||||
string serverUrl = builder.Configuration["AGUI_SERVER_URL"] ?? "http://localhost:5100";
|
||||
|
||||
builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl));
|
||||
|
||||
builder.Services.AddChatClient(sp => new AGUIChatClient(new(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("aguiserver"), "ag-ui")));
|
||||
```
|
||||
|
||||
The Blazor UI (`Client/Components/Pages/Chat/Chat.razor`) uses the `IChatClient` to:
|
||||
- Send user messages to the agent
|
||||
- Stream responses back in real-time
|
||||
- Maintain conversation history
|
||||
- Display messages with appropriate styling
|
||||
|
||||
### UI Components
|
||||
|
||||
The chat interface is built from several Blazor components:
|
||||
|
||||
- **Chat.razor** - Main chat page coordinating the conversation flow
|
||||
- **ChatHeader.razor** - Header with "New chat" button
|
||||
- **ChatMessageList.razor** - Scrollable list of messages with auto-scroll
|
||||
- **ChatMessageItem.razor** - Individual message rendering (user vs assistant)
|
||||
- **ChatInput.razor** - Text input with auto-resize and keyboard shortcuts
|
||||
- **ChatSuggestions.razor** - AI-generated follow-up question suggestions
|
||||
- **LoadingSpinner.razor** - Animated loading indicator during streaming
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Configuration
|
||||
|
||||
The server URL and port are configured in `Server/Properties/launchSettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"applicationUrl": "http://localhost:5100"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Client Configuration
|
||||
|
||||
The client connects to the server URL specified in `Client/Properties/launchSettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"AGUI_SERVER_URL": "http://localhost:5100"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To change the server URL, modify the `AGUI_SERVER_URL` environment variable in the client's launch settings or provide it at runtime:
|
||||
|
||||
```powershell
|
||||
$env:AGUI_SERVER_URL="http://your-server:5100"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing the Agent Instructions
|
||||
|
||||
Edit the instructions in `Server/Program.cs`:
|
||||
|
||||
```csharp
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful coding assistant specializing in C# and .NET.");
|
||||
```
|
||||
|
||||
### Styling the UI
|
||||
|
||||
The chat interface uses CSS files colocated with each Razor component. Key styles:
|
||||
|
||||
- `wwwroot/app.css` - Global styles, buttons, color scheme
|
||||
- `Components/Pages/Chat/Chat.razor.css` - Chat container layout
|
||||
- `Components/Pages/Chat/ChatMessageItem.razor.css` - Message bubbles and icons
|
||||
- `Components/Pages/Chat/ChatInput.razor.css` - Input box styling
|
||||
|
||||
### Disabling Suggestions
|
||||
|
||||
To disable the AI-generated follow-up suggestions, comment out the suggestions component in `Chat.razor`:
|
||||
|
||||
```razor
|
||||
@* <ChatSuggestions OnSelected="@AddUserMessageAsync" @ref="@chatSuggestions" /> *@
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a basic AG-UI server hosting a chat agent for the Blazor web client.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
|
||||
|
||||
// Create the AI agent
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient azureOpenAIClient = new(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "ChatAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUIServer("/ag-ui", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5100",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AgentWebChat.AgentHost;
|
||||
|
||||
internal static class ActorFrameworkWebApplicationExtensions
|
||||
{
|
||||
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
|
||||
{
|
||||
var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey);
|
||||
|
||||
var routeGroup = endpoints.MapGroup(path);
|
||||
routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
|
||||
{
|
||||
var results = new List<AgentDiscoveryCard>();
|
||||
foreach (var result in registeredAIAgents)
|
||||
{
|
||||
results.Add(new AgentDiscoveryCard
|
||||
{
|
||||
Name = result.Name!,
|
||||
Description = result.Description,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(results);
|
||||
})
|
||||
.WithName("GetAgents");
|
||||
}
|
||||
|
||||
internal sealed class AgentDiscoveryCard
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
|
||||
<PackageReference Include="Aspire.Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentWebChat.AgentHost.Custom;
|
||||
|
||||
public class CustomAITool : AITool;
|
||||
|
||||
public class CustomFunctionTool : AIFunction
|
||||
{
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<object?>(arguments.Context?.Count ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentWebChat.AgentHost;
|
||||
using AgentWebChat.AgentHost.Custom;
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add service defaults & Aspire client integrations.
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Configure the chat model and our agent.
|
||||
builder.AddKeyedChatClient("chat-model");
|
||||
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
|
||||
// Add OpenAI services
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// agentBuilder.WithInMemorySessionStore();
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
description: "An agent that speaks like a pirate.",
|
||||
chatClientServiceKey: "chat-model")
|
||||
.WithAITool(new CustomAITool())
|
||||
.WithAITool(new CustomFunctionTool())
|
||||
.WithInMemorySessionStore();
|
||||
|
||||
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
|
||||
ChatClientAgent knight = new(
|
||||
chatClient,
|
||||
"""
|
||||
You are a knight. This means that you must always tell the truth. Your name is Alice.
|
||||
Bob is standing next to you. Bob is a knave, which means he always lies.
|
||||
When replying, always start with your name (Alice). Eg, "Alice: I am a knight."
|
||||
""", "Alice");
|
||||
|
||||
ChatClientAgent knave = new(
|
||||
chatClient,
|
||||
"""
|
||||
You are a knave. This means that you must always lie. Your name is Bob.
|
||||
Alice is standing next to you. Alice is a knight, which means she always tells the truth.
|
||||
When replying, always include your name (Bob). Eg, "Bob: I am a knight."
|
||||
""", "Bob");
|
||||
|
||||
ChatClientAgent narrator = new(
|
||||
chatClient,
|
||||
"""
|
||||
You are are the narrator of a puzzle involving knights (who always tell the truth) and knaves (who always lie).
|
||||
The user is going to ask questions and guess whether Alice or Bob is the knight or knave.
|
||||
Alice is standing to one side of you. Alice is a knight, which means she always tells the truth.
|
||||
Bob is standing to the other side of you. Bob is a knave, which means he always lies.
|
||||
When replying, always include your name (Narrator).
|
||||
Once the user has deduced what type (knight or knave) both Alice and Bob are, tell them whether they are right or wrong.
|
||||
If the user asks a general question about their surrounding, make something up which is consistent with the scenario.
|
||||
""", "Narrator");
|
||||
|
||||
return AgentWorkflowBuilder.BuildConcurrent([knight, knave, narrator]).AsAIAgent(name: key);
|
||||
});
|
||||
|
||||
// Workflow consisting of multiple specialized agents
|
||||
var chemistryAgent = builder.AddAIAgent("chemist",
|
||||
instructions: "You are a chemistry expert. Answer thinking from the chemistry perspective",
|
||||
description: "An agent that helps with chemistry.",
|
||||
chatClientServiceKey: "chat-model");
|
||||
|
||||
var mathsAgent = builder.AddAIAgent("mathematician",
|
||||
instructions: "You are a mathematics expert. Answer thinking from the maths perspective",
|
||||
description: "An agent that helps with mathematics.",
|
||||
chatClientServiceKey: "chat-model");
|
||||
|
||||
var literatureAgent = builder.AddAIAgent("literator",
|
||||
instructions: "You are a literature expert. Answer thinking from the literature perspective",
|
||||
description: "An agent that helps with literature.",
|
||||
chatClientServiceKey: "chat-model");
|
||||
|
||||
var scienceSequentialWorkflow = builder.AddWorkflow("science-sequential-workflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [chemistryAgent, mathsAgent, literatureAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [chemistryAgent, mathsAgent, literatureAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<AIAgent>(sp =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("my-di-nonmatching-agent", (sp, name) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "some-random-name", // demonstrating registration can be different for DI and actual agent
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, name) =>
|
||||
{
|
||||
if (name is not string nameStr)
|
||||
{
|
||||
throw new NotSupportedException("Name should be passed as a key");
|
||||
}
|
||||
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: nameStr, // demonstrating registration with the same name
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents API"));
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// Expose A2A servers over HTTP with JSON payloads
|
||||
app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate");
|
||||
app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIResponses(pirateAgentBuilder);
|
||||
app.MapOpenAIResponses(knightsKnavesAgentBuilder);
|
||||
app.MapOpenAIResponses(chemistryAgent);
|
||||
app.MapOpenAIResponses(mathsAgent);
|
||||
app.MapOpenAIResponses(literatureAgent);
|
||||
app.MapOpenAIResponses(scienceSequentialWorkflow);
|
||||
app.MapOpenAIResponses(scienceConcurrentWorkflow);
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapOpenAIChatCompletions(pirateAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(chemistryAgent);
|
||||
app.MapOpenAIChatCompletions(mathsAgent);
|
||||
app.MapOpenAIChatCompletions(literatureAgent);
|
||||
app.MapOpenAIChatCompletions(scienceSequentialWorkflow);
|
||||
app.MapOpenAIChatCompletions(scienceConcurrentWorkflow);
|
||||
|
||||
// Map the agents HTTP endpoints
|
||||
app.MapAgentDiscovery("/agents");
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
app.Run();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5390",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7373;http://localhost:5390",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AgentWebChat.AgentHost.Utilities;
|
||||
|
||||
public class ChatClientConnectionInfo
|
||||
{
|
||||
public Uri? Endpoint { get; init; }
|
||||
public required string SelectedModel { get; init; }
|
||||
|
||||
public ClientChatProvider Provider { get; init; }
|
||||
public string? AccessKey { get; init; }
|
||||
|
||||
// Example connection string:
|
||||
// Endpoint=https://localhost:4523;Model=phi3.5;AccessKey=1234;Provider=ollama;
|
||||
public static bool TryParse(string? connectionString, [NotNullWhen(true)] out ChatClientConnectionInfo? settings)
|
||||
{
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
settings = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var connectionBuilder = new DbConnectionStringBuilder
|
||||
{
|
||||
ConnectionString = connectionString
|
||||
};
|
||||
|
||||
Uri? endpoint = null;
|
||||
if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out endpoint))
|
||||
{
|
||||
}
|
||||
|
||||
string? model = null;
|
||||
if (connectionBuilder.ContainsKey("Model"))
|
||||
{
|
||||
model = (string)connectionBuilder["Model"];
|
||||
}
|
||||
|
||||
string? accessKey = null;
|
||||
if (connectionBuilder.ContainsKey("AccessKey"))
|
||||
{
|
||||
accessKey = (string)connectionBuilder["AccessKey"];
|
||||
}
|
||||
|
||||
var provider = ClientChatProvider.Unknown;
|
||||
if (connectionBuilder.ContainsKey("Provider"))
|
||||
{
|
||||
var providerValue = (string)connectionBuilder["Provider"];
|
||||
Enum.TryParse(providerValue, ignoreCase: true, out provider);
|
||||
}
|
||||
|
||||
if ((endpoint is null && provider != ClientChatProvider.OpenAI) || model is null || provider is ClientChatProvider.Unknown)
|
||||
{
|
||||
settings = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
settings = new ChatClientConnectionInfo
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
SelectedModel = model,
|
||||
AccessKey = accessKey,
|
||||
Provider = provider
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClientChatProvider
|
||||
{
|
||||
Unknown,
|
||||
Ollama,
|
||||
OpenAI,
|
||||
AzureOpenAI,
|
||||
AzureAIInference,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OllamaSharp;
|
||||
|
||||
namespace AgentWebChat.AgentHost.Utilities;
|
||||
|
||||
public static class ChatClientExtensions
|
||||
{
|
||||
public static ChatClientBuilder AddChatClient(this IHostApplicationBuilder builder, string connectionName)
|
||||
{
|
||||
var cs = builder.Configuration.GetConnectionString(connectionName);
|
||||
|
||||
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
|
||||
}
|
||||
|
||||
var chatClientBuilder = connectionInfo.Provider switch
|
||||
{
|
||||
ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo),
|
||||
ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo),
|
||||
ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel),
|
||||
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
|
||||
};
|
||||
|
||||
// Add OpenTelemetry tracing for the ChatClient activity source
|
||||
chatClientBuilder.UseOpenTelemetry().UseLogging();
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
|
||||
|
||||
return chatClientBuilder;
|
||||
}
|
||||
|
||||
private static ChatClientBuilder AddOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
|
||||
builder.AddOpenAIClient(connectionName, settings =>
|
||||
{
|
||||
settings.Endpoint = connectionInfo.Endpoint;
|
||||
settings.Key = connectionInfo.AccessKey;
|
||||
})
|
||||
.AddChatClient(connectionInfo.SelectedModel);
|
||||
|
||||
private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
|
||||
{
|
||||
var httpKey = $"{connectionName}_http";
|
||||
|
||||
builder.Services.AddHttpClient(httpKey, c => c.BaseAddress = connectionInfo.Endpoint);
|
||||
|
||||
return builder.Services.AddChatClient(sp =>
|
||||
{
|
||||
// Create a client for the Ollama API using the http client factory
|
||||
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
|
||||
|
||||
return new OllamaApiClient(client, connectionInfo.SelectedModel);
|
||||
});
|
||||
}
|
||||
|
||||
public static ChatClientBuilder AddKeyedChatClient(this IHostApplicationBuilder builder, string connectionName)
|
||||
{
|
||||
var cs = builder.Configuration.GetConnectionString(connectionName);
|
||||
|
||||
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
|
||||
}
|
||||
|
||||
var chatClientBuilder = connectionInfo.Provider switch
|
||||
{
|
||||
ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo),
|
||||
ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo),
|
||||
ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel),
|
||||
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
|
||||
};
|
||||
|
||||
// Add OpenTelemetry tracing for the ChatClient activity source
|
||||
chatClientBuilder.UseOpenTelemetry().UseLogging();
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
|
||||
|
||||
return chatClientBuilder;
|
||||
}
|
||||
|
||||
private static ChatClientBuilder AddKeyedOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
|
||||
builder.AddKeyedOpenAIClient(connectionName, settings =>
|
||||
{
|
||||
settings.Endpoint = connectionInfo.Endpoint;
|
||||
settings.Key = connectionInfo.AccessKey;
|
||||
})
|
||||
.AddKeyedChatClient(connectionName, connectionInfo.SelectedModel);
|
||||
|
||||
private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
|
||||
{
|
||||
var httpKey = $"{connectionName}_http";
|
||||
|
||||
builder.Services.AddHttpClient(httpKey, c => c.BaseAddress = connectionInfo.Endpoint);
|
||||
|
||||
return builder.Services.AddKeyedChatClient(connectionName, sp =>
|
||||
{
|
||||
// Create a client for the Ollama API using the http client factory
|
||||
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
|
||||
|
||||
return new OllamaApiClient(client, connectionInfo.SelectedModel);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="$(AspireAppHostSdkVersion)" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireHost>true</IsAspireHost>
|
||||
<UserSecretsId>2969a84d-8ee6-4304-8737-6e469a315aa8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" />
|
||||
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AgentWebChat.AgentHost\AgentWebChat.AgentHost.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.Web\AgentWebChat.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AgentWebChat.AppHost;
|
||||
|
||||
public static class ModelExtensions
|
||||
{
|
||||
public static IResourceBuilder<AIModel> AddAIModel(this IDistributedApplicationBuilder builder, string name)
|
||||
{
|
||||
var model = new AIModel(name);
|
||||
return builder.CreateResourceBuilder(model);
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> RunAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
return builder.AsOpenAI(modelName, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> PublishAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
|
||||
{
|
||||
return builder.AsOpenAI(modelName, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> RunAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
return builder.AsAzureOpenAI(modelName, configure);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> PublishAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
|
||||
{
|
||||
return builder.AsAzureOpenAI(modelName, configure);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> AsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
|
||||
{
|
||||
builder.Reset();
|
||||
|
||||
var openAIModel = builder.ApplicationBuilder.AddAzureOpenAI(builder.Resource.Name);
|
||||
|
||||
configure?.Invoke(openAIModel);
|
||||
|
||||
builder.Resource.UnderlyingResource = openAIModel.Resource;
|
||||
// Add the model name to the connection string
|
||||
builder.Resource.ConnectionString = ReferenceExpression.Create($"{openAIModel.Resource.ConnectionStringExpression};Model={modelName}");
|
||||
builder.Resource.Provider = "AzureOpenAI";
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
|
||||
{
|
||||
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
builder.Reset();
|
||||
|
||||
// See: https://github.com/dotnet/aspire/issues/7641
|
||||
var csb = new ReferenceExpressionBuilder();
|
||||
csb.Append($"Endpoint={endpoint.Resource};");
|
||||
csb.Append($"AccessKey={apiKey.Resource};");
|
||||
csb.Append($"Model={modelName}");
|
||||
var cs = csb.Build();
|
||||
|
||||
builder.ApplicationBuilder.AddResource(builder.Resource);
|
||||
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
var csTask = cs.GetValueAsync(default).AsTask();
|
||||
if (!csTask.IsCompletedSuccessfully)
|
||||
{
|
||||
throw new InvalidOperationException("Connection string could not be resolved!");
|
||||
}
|
||||
|
||||
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
|
||||
builder.WithInitialState(new CustomResourceSnapshot
|
||||
{
|
||||
ResourceType = "Azure AI Inference Model",
|
||||
State = KnownResourceStates.Running,
|
||||
Properties = [
|
||||
new("ConnectionString", csTask.Result ) { IsSensitive = true }
|
||||
]
|
||||
});
|
||||
#pragma warning restore VSTHRD002
|
||||
}
|
||||
|
||||
builder.Resource.UnderlyingResource = builder.Resource;
|
||||
builder.Resource.ConnectionString = cs;
|
||||
builder.Resource.Provider = "AzureAIInference";
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
|
||||
{
|
||||
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
builder.Reset();
|
||||
|
||||
// See: https://github.com/dotnet/aspire/issues/7641
|
||||
var csb = new ReferenceExpressionBuilder();
|
||||
csb.Append($"Endpoint={endpoint};");
|
||||
csb.Append($"AccessKey={apiKey.Resource};");
|
||||
csb.Append($"Model={modelName}");
|
||||
var cs = csb.Build();
|
||||
|
||||
builder.ApplicationBuilder.AddResource(builder.Resource);
|
||||
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
var csTask = cs.GetValueAsync(default).AsTask();
|
||||
if (!csTask.IsCompletedSuccessfully)
|
||||
{
|
||||
throw new InvalidOperationException("Connection string could not be resolved!");
|
||||
}
|
||||
|
||||
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
|
||||
builder.WithInitialState(new CustomResourceSnapshot
|
||||
{
|
||||
ResourceType = "Azure AI Inference Model",
|
||||
State = KnownResourceStates.Running,
|
||||
Properties = [
|
||||
new("ConnectionString", csTask.Result ) { IsSensitive = true }
|
||||
]
|
||||
});
|
||||
#pragma warning restore VSTHRD002
|
||||
}
|
||||
|
||||
builder.Resource.UnderlyingResource = builder.Resource;
|
||||
builder.Resource.ConnectionString = cs;
|
||||
builder.Resource.Provider = "AzureAIInference";
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<AIModel> AsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
|
||||
{
|
||||
builder.Reset();
|
||||
|
||||
// See: https://github.com/dotnet/aspire/issues/7641
|
||||
var csb = new ReferenceExpressionBuilder();
|
||||
csb.Append($"AccessKey={apiKey.Resource};");
|
||||
csb.Append($"Model={modelName}");
|
||||
var cs = csb.Build();
|
||||
|
||||
builder.ApplicationBuilder.AddResource(builder.Resource);
|
||||
|
||||
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
var csTask = cs.GetValueAsync(default).AsTask();
|
||||
if (!csTask.IsCompletedSuccessfully)
|
||||
{
|
||||
throw new InvalidOperationException("Connection string could not be resolved!");
|
||||
}
|
||||
|
||||
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
|
||||
builder.WithInitialState(new CustomResourceSnapshot
|
||||
{
|
||||
ResourceType = "OpenAI Model",
|
||||
State = KnownResourceStates.Running,
|
||||
Properties = [
|
||||
new("ConnectionString", csTask.Result ) { IsSensitive = true }
|
||||
]
|
||||
});
|
||||
#pragma warning restore VSTHRD002
|
||||
}
|
||||
|
||||
builder.Resource.UnderlyingResource = builder.Resource;
|
||||
builder.Resource.ConnectionString = cs;
|
||||
builder.Resource.Provider = "OpenAI";
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void Reset(this IResourceBuilder<AIModel> builder)
|
||||
{
|
||||
// Reset the properties of the AIModel resource
|
||||
if (builder.Resource.UnderlyingResource is { } underlyingResource)
|
||||
{
|
||||
builder.ApplicationBuilder.Resources.Remove(underlyingResource);
|
||||
|
||||
if (underlyingResource is IResourceWithParent resourceWithParent)
|
||||
{
|
||||
builder.ApplicationBuilder.Resources.Remove(resourceWithParent.Parent);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Resource.ConnectionString = null;
|
||||
builder.Resource.Provider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A resource representing an AI model.
|
||||
public class AIModel(string name) : Resource(name), IResourceWithConnectionString
|
||||
{
|
||||
internal string? Provider { get; set; }
|
||||
internal IResourceWithConnectionString? UnderlyingResource { get; set; }
|
||||
internal ReferenceExpression? ConnectionString { get; set; }
|
||||
|
||||
public ReferenceExpression ConnectionStringExpression =>
|
||||
this.Build();
|
||||
|
||||
public ReferenceExpression Build()
|
||||
{
|
||||
var connectionString = this.ConnectionString ?? throw new InvalidOperationException("No connection string available.");
|
||||
|
||||
if (this.Provider is null)
|
||||
{
|
||||
throw new InvalidOperationException("No provider configured.");
|
||||
}
|
||||
|
||||
return ReferenceExpression.Create($"{connectionString};Provider={this.Provider}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentWebChat.AppHost;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var azOpenAiResource = builder.AddParameterFromConfiguration("AzureOpenAIName", "AzureOpenAI:Name");
|
||||
var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIResourceGroup", "AzureOpenAI:ResourceGroup");
|
||||
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-5.4-mini", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
|
||||
|
||||
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
|
||||
.WithHttpEndpoint(name: "devui")
|
||||
.WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
|
||||
.WithReference(chatModel);
|
||||
|
||||
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithReference(agentHost)
|
||||
.WaitFor(agentHost);
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17277;http://localhost:15143",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22278"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15143",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19242",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20010"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||
public static class ServiceDefaultsExtensions
|
||||
{
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Trace);
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddSource("*Microsoft.Agents.AI")
|
||||
.AddSource("Microsoft.Agents.AI.Runtime.InProcess")
|
||||
.AddSource("Microsoft.Agents.AI.Runtime.Abstractions.InMemoryActorStateStorage")
|
||||
.AddAspNetCoreInstrumentation()
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks("/alive", new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentWebChat.Web;
|
||||
|
||||
internal sealed class A2AAgentClient : AgentClientBase
|
||||
{
|
||||
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 = [];
|
||||
|
||||
public A2AAgentClient(ILogger logger, Uri baseUri)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._uri = baseUri;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
|
||||
string agentName,
|
||||
IList<ChatMessage> messages,
|
||||
string? sessionId = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._logger.LogInformation("Running agent {AgentName} with {MessageCount} messages via A2A", agentName, messages.Count);
|
||||
|
||||
var (a2aClient, _) = this.ResolveClient(agentName);
|
||||
var contextId = sessionId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
// Convert and send messages via A2A without try-catch in yield method
|
||||
var results = new List<AgentResponseUpdate>();
|
||||
|
||||
try
|
||||
{
|
||||
// Convert all messages to A2A parts and create a single message
|
||||
var parts = messages.ToParts();
|
||||
var a2aMessage = new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.User,
|
||||
Parts = parts
|
||||
};
|
||||
|
||||
var messageSendParams = new SendMessageRequest { Message = a2aMessage };
|
||||
var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken);
|
||||
|
||||
// Handle different response types
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
|
||||
{
|
||||
var message = a2aResponse.Message!;
|
||||
var responseMessage = message.ToChatMessage();
|
||||
if (responseMessage is { Contents.Count: > 0 })
|
||||
{
|
||||
results.Add(new AgentResponseUpdate(responseMessage.Role, responseMessage.Contents)
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
|
||||
{
|
||||
// Manually convert AgentTask artifacts to ChatMessages since the extension method is internal
|
||||
var agentTask = a2aResponse.Task!;
|
||||
if (agentTask.Artifacts is not null)
|
||||
{
|
||||
foreach (var artifact in agentTask.Artifacts)
|
||||
{
|
||||
List<AIContent>? aiContents = null;
|
||||
|
||||
foreach (var part in artifact.Parts)
|
||||
{
|
||||
(aiContents ??= []).Add(part.ToAIContent());
|
||||
}
|
||||
|
||||
if (aiContents is not null)
|
||||
{
|
||||
var additionalProperties = ConvertMetadataToAdditionalProperties(artifact.Metadata);
|
||||
var chatMessage = new ChatMessage(ChatRole.Assistant, aiContents)
|
||||
{
|
||||
AdditionalProperties = additionalProperties,
|
||||
RawRepresentation = artifact,
|
||||
};
|
||||
|
||||
results.Add(new AgentResponseUpdate(chatMessage.Role, chatMessage.Contents)
|
||||
{
|
||||
MessageId = agentTask.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this._logger.LogWarning("Unsupported A2A response type: {ResponseType}", a2aResponse?.GetType().FullName ?? "null");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error running agent {AgentName} via A2A", agentName);
|
||||
|
||||
results.Add(new AgentResponseUpdate(ChatRole.Assistant, $"Error: {ex.Message}")
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// Yield the results
|
||||
foreach (var result in results)
|
||||
{
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<AgentCard?> GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._logger.LogInformation("Retrieving agent card for {Agent}", agentName);
|
||||
|
||||
var (_, a2aCardResolver) = this.ResolveClient(agentName);
|
||||
try
|
||||
{
|
||||
return await a2aCardResolver.GetAgentCardAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Failed to get agent card for {AgentName}", agentName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private (A2AClient, A2ACardResolver) ResolveClient(string agentName) =>
|
||||
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 static AdditionalPropertiesDictionary? ConvertMetadataToAdditionalProperties(Dictionary<string, JsonElement>? metadata)
|
||||
{
|
||||
if (metadata is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var additionalProperties = new AdditionalPropertiesDictionary();
|
||||
foreach (var kvp in metadata)
|
||||
{
|
||||
additionalProperties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return additionalProperties;
|
||||
}
|
||||
}
|
||||