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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
# AG-UI Getting Started Samples
This directory contains samples that demonstrate how to build AG-UI (Agent UI Protocol) servers and clients using the Microsoft Agent Framework.
## Prerequisites
- .NET 9.0 or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (`az login`)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
## Environment Variables
All samples require the following environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
```
For the client samples, you can optionally set:
```bash
export AGUI_SERVER_URL="http://localhost:8888"
```
## Samples
### Step01_GettingStarted
A basic AG-UI server and client that demonstrate the foundational concepts.
#### Server (`Step01_GettingStarted/Server`)
A basic AG-UI server that hosts an AI agent accessible via HTTP. Demonstrates:
- Creating an ASP.NET Core web application
- Setting up an AG-UI server endpoint with `MapAGUIServer`
- Creating an AI agent from an Azure OpenAI chat client
- Streaming responses via Server-Sent Events (SSE)
**Run the server:**
```bash
cd Step01_GettingStarted/Server
dotnet run --urls http://localhost:8888
```
#### Client (`Step01_GettingStarted/Client`)
An interactive console client that connects to an AG-UI server. Demonstrates:
- Creating an AG-UI client with `AGUIChatClient`
- Managing conversation threads
- Streaming responses with `RunStreamingAsync`
- Displaying colored console output for different content types
- Supporting both interactive and automated modes
**Prerequisites:** The Step01_GettingStarted server (or any AG-UI server) must be running.
**Run the client:**
```bash
cd Step01_GettingStarted/Client
dotnet run
```
Type messages and press Enter to interact with the agent. Type `:q` or `quit` to exit.
### Step02_BackendTools
An AG-UI server with function tools that execute on the backend.
#### Server (`Step02_BackendTools/Server`)
Demonstrates:
- Creating function tools using `AIFunctionFactory.Create`
- Using `[Description]` attributes for tool documentation
- Defining explicit request/response types for type safety
- Setting up JSON serialization contexts for source generation
- Backend tool rendering (tools execute on the server)
**Run the server:**
```bash
cd Step02_BackendTools/Server
dotnet run --urls http://localhost:8888
```
#### Client (`Step02_BackendTools/Client`)
A client that works with the backend tools server. Try asking: "Find Italian restaurants in Seattle" or "Search for Mexican food in Portland".
**Run the client:**
```bash
cd Step02_BackendTools/Client
dotnet run
```
### Step03_FrontendTools
Demonstrates frontend tool rendering (tools defined on client, executed on server).
#### Server (`Step03_FrontendTools/Server`)
A basic AG-UI server that accepts tool definitions from the client.
**Run the server:**
```bash
cd Step03_FrontendTools/Server
dotnet run --urls http://localhost:8888
```
#### Client (`Step03_FrontendTools/Client`)
A client that defines and sends tools to the server for execution.
**Run the client:**
```bash
cd Step03_FrontendTools/Client
dotnet run
```
### Step04_HumanInLoop
Demonstrates human-in-the-loop approval workflows for sensitive operations. This sample includes both a server and client component.
#### Server (`Step04_HumanInLoop/Server`)
An AG-UI server that implements approval workflows. Demonstrates:
- Wrapping tools with `ApprovalRequiredAIFunction`
- Converting `FunctionApprovalRequestContent` to approval requests
- Middleware pattern with `ServerFunctionApprovalServerAgent`
- Complete function call capture and restoration
**Run the server:**
```bash
cd Step04_HumanInLoop/Server
dotnet run --urls http://localhost:8888
```
#### Client (`Step04_HumanInLoop/Client`)
An interactive client that handles approval requests from the server. Demonstrates:
- Using `ServerFunctionApprovalClientAgent` middleware
- Detecting `FunctionApprovalRequestContent`
- Displaying approval details to users
- Prompting for approval/rejection
- Sending approval responses with `FunctionApprovalResponseContent`
- Resuming conversation after approval
**Run the client:**
```bash
cd Step04_HumanInLoop/Client
dotnet run
```
Try asking the agent to perform sensitive operations like "Approve expense report EXP-12345".
### Step05_StateManagement
An AG-UI server and client that demonstrate state management with predictive updates.
#### Server (`Step05_StateManagement/Server`)
Demonstrates:
- Defining state schemas using C# records
- Using `SharedStateAgent` middleware for state management
- Streaming predictive state updates with `AgentState` content
- Managing shared state between client and server
- Using JSON serialization contexts for state types
**Run the server:**
```bash
cd Step05_StateManagement/Server
dotnet run
```
The server runs on port 8888 by default.
#### Client (`Step05_StateManagement/Client`)
A client that displays and updates shared state from the server. Try asking: "Create a recipe for chocolate chip cookies" or "Suggest a pasta dish".
**Run the client:**
```bash
cd Step05_StateManagement/Client
dotnet run
```
## How AG-UI Works
### Server-Side
1. Client sends HTTP POST request with messages
2. ASP.NET Core endpoint receives the request via `MapAGUIServer`
3. Agent processes messages using Agent Framework
4. Responses are streamed back as Server-Sent Events (SSE)
### Client-Side
1. `AGUIAgent` sends HTTP POST request to server
2. Server responds with SSE stream
3. Client parses events into `AgentResponseUpdate` objects
4. Updates are displayed based on content type
5. The client sends the full message history each turn (the stateless AG-UI client does not rely on a server-assigned `ConversationId`)
### Protocol Features
- **HTTP POST** for requests
- **Server-Sent Events (SSE)** for streaming responses
- **JSON** for event serialization
- **Thread IDs** (read from the `RUN_STARTED` event's raw representation) for conversation context. `AGUIChatClient` is stateless and intentionally does not surface a `ConversationId`.
- **Run IDs** (as `ResponseId`) for tracking individual executions
## Security considerations
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
## Troubleshooting
### Connection Refused
Ensure the server is running before starting the client:
```bash
# Terminal 1
cd AGUI_Step01_ServerBasic
dotnet run --urls http://localhost:8888
# Terminal 2 (after server starts)
cd AGUI_Step02_ClientBasic
dotnet run
```
### Port Already in Use
If port 8888 is already in use, choose a different port:
```bash
# Server
dotnet run --urls http://localhost:8889
# Client (set environment variable)
export AGUI_SERVER_URL="http://localhost:8889"
dotnet run
```
### Authentication Errors
Make sure you're authenticated with Azure:
```bash
az login
```
Verify you have the `Cognitive Services OpenAI Contributor` role on the Azure OpenAI resource.
### Missing Environment Variables
If you see "AZURE_OPENAI_ENDPOINT is not set" errors, ensure environment variables are set in your current shell session before running the samples.
### Streaming Not Working
Check that the client timeout is sufficient (default is 60 seconds). For long-running operations, you may need to increase the timeout in the client code.
## Next Steps
After completing these samples, explore more AG-UI capabilities:
### Currently Available in C#
The samples above demonstrate the AG-UI features currently available in C#:
-**Basic Server and Client**: Setting up AG-UI communication
-**Backend Tool Rendering**: Function tools that execute on the server
-**Streaming Responses**: Real-time Server-Sent Events
-**State Management**: State schemas with predictive updates
-**Human-in-the-Loop**: Approval workflows for sensitive operations
### Coming Soon to C#
The following advanced AG-UI features are available in the Python implementation and are planned for future C# releases:
-**Generative UI**: Custom UI component generation
-**Advanced State Patterns**: Complex state synchronization scenarios
For the most up-to-date AG-UI features, see the [Python samples](../../../../python/samples/) for working examples.
### Related Documentation
- [AG-UI Overview](https://learn.microsoft.com/agent-framework/integrations/ag-ui/) - Complete AG-UI documentation
- [Getting Started Tutorial](https://learn.microsoft.com/agent-framework/integrations/ag-ui/getting-started) - Step-by-step walkthrough
- [Backend Tool Rendering](https://learn.microsoft.com/agent-framework/integrations/ag-ui/backend-tool-rendering) - Function tools tutorial
- [Human-in-the-Loop](https://learn.microsoft.com/agent-framework/integrations/ag-ui/human-in-the-loop) - Approval workflows tutorial
- [State Management](https://learn.microsoft.com/agent-framework/integrations/ag-ui/state-management) - State management tutorial
- [Agent Framework Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview) - Core framework concepts
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<PackageReference Include="AGUI.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUI.Abstractions;
using AGUI.Client;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n");
// Create the AG-UI client agent
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent");
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant.")
];
try
{
while (true)
{
// Get user input
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 ChatMessage(ChatRole.User, message));
// Stream the response
bool isFirstUpdate = true;
string? threadId = null;
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
// First update indicates run started
if (isFirstUpdate)
{
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
// id is carried on the AG-UI RUN_STARTED event's raw representation.
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
Console.ResetColor();
isFirstUpdate = false;
}
// Display streaming text content
foreach (AIContent content in update.Contents)
{
if (content is TextContent textContent)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(textContent.Text);
Console.ResetColor();
}
else if (content is ErrorContent errorContent)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[Error: {errorContent.Message}]");
Console.ResetColor();
}
}
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.WriteLine($"\nAn error occurred: {ex.Message}");
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
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.
ChatClient chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
// Map the AG-UI agent endpoint
app.MapAGUIServer("/", agent);
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7047;http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<PackageReference Include="AGUI.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUI.Abstractions;
using AGUI.Client;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n");
// Create the AG-UI client agent
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent");
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant.")
];
try
{
while (true)
{
// Get user input
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 ChatMessage(ChatRole.User, message));
// Stream the response
bool isFirstUpdate = true;
string? threadId = null;
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
// First update indicates run started
if (isFirstUpdate)
{
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
// id is carried on the AG-UI RUN_STARTED event's raw representation.
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
Console.ResetColor();
isFirstUpdate = false;
}
// Display streaming content
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}]");
// Display individual parameters
if (functionCallContent.Arguments != null)
{
foreach (var kvp in functionCallContent.Arguments)
{
Console.WriteLine($" Parameter: {kvp.Key} = {kvp.Value}");
}
}
Console.ResetColor();
break;
case FunctionResultContent functionResultContent:
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"\n[Function Result - CallId: {functionResultContent.CallId}]");
if (functionResultContent.Exception != null)
{
Console.WriteLine($" Exception: {functionResultContent.Exception}");
}
else
{
Console.WriteLine($" Result: {functionResultContent.Result}");
}
Console.ResetColor();
break;
case ErrorContent errorContent:
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[Error: {errorContent.Message}]");
Console.ResetColor();
break;
}
}
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.WriteLine($"\nAn error occurred: {ex.Message}");
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text.Json.Serialization;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.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();
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.");
// Define the function tool
[Description("Search for restaurants in a location.")]
static RestaurantSearchResponse SearchRestaurants(
[Description("The restaurant search request")] RestaurantSearchRequest request)
{
// Simulated restaurant data
string cuisine = request.Cuisine == "any" ? "Italian" : request.Cuisine;
return new RestaurantSearchResponse
{
Location = request.Location,
Cuisine = request.Cuisine,
Results =
[
new RestaurantInfo
{
Name = "The Golden Fork",
Cuisine = cuisine,
Rating = 4.5,
Address = $"123 Main St, {request.Location}"
},
new RestaurantInfo
{
Name = "Spice Haven",
Cuisine = cuisine == "Italian" ? "Indian" : cuisine,
Rating = 4.7,
Address = $"456 Oak Ave, {request.Location}"
},
new RestaurantInfo
{
Name = "Green Leaf",
Cuisine = "Vegetarian",
Rating = 4.3,
Address = $"789 Elm Rd, {request.Location}"
}
]
};
}
// Get JsonSerializerOptions from the configured HTTP JSON options
Microsoft.AspNetCore.Http.Json.JsonOptions jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>().Value;
// Create tool with serializer options
AITool[] tools =
[
AIFunctionFactory.Create(
SearchRestaurants,
serializerOptions: jsonOptions.SerializerOptions)
];
// 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.
ChatClient chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant with access to restaurant information.",
tools: tools);
// Map the AG-UI agent endpoint
app.MapAGUIServer("/", agent);
await app.RunAsync();
// Define request/response types for the tool
internal sealed class RestaurantSearchRequest
{
public string Location { get; set; } = string.Empty;
public string Cuisine { get; set; } = "any";
}
internal sealed class RestaurantSearchResponse
{
public string Location { get; set; } = string.Empty;
public string Cuisine { get; set; } = string.Empty;
public RestaurantInfo[] Results { get; set; } = [];
}
internal sealed class RestaurantInfo
{
public string Name { get; set; } = string.Empty;
public string Cuisine { get; set; } = string.Empty;
public double Rating { get; set; }
public string Address { get; set; } = string.Empty;
}
// JSON serialization context for source generation
[JsonSerializable(typeof(RestaurantSearchRequest))]
[JsonSerializable(typeof(RestaurantSearchResponse))]
internal sealed partial class SampleJsonSerializerContext : JsonSerializerContext;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7047;http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<PackageReference Include="AGUI.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using AGUI.Abstractions;
using AGUI.Client;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n");
// Define a frontend function tool
[Description("Get the user's current location from GPS.")]
static string GetUserLocation()
{
// Access client-side GPS
return "Amsterdam, Netherlands (52.37°N, 4.90°E)";
}
// Create frontend tools
AITool[] frontendTools = [AIFunctionFactory.Create(GetUserLocation)];
// Create the AG-UI client agent with tools
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent",
tools: frontendTools);
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant.")
];
try
{
while (true)
{
// Get user input
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 ChatMessage(ChatRole.User, message));
// Stream the response
bool isFirstUpdate = true;
string? threadId = null;
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
// First update indicates run started
if (isFirstUpdate)
{
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
// id is carried on the AG-UI RUN_STARTED event's raw representation.
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
Console.ResetColor();
isFirstUpdate = false;
}
// Display streaming content
foreach (AIContent content in update.Contents)
{
if (content is TextContent textContent)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(textContent.Text);
Console.ResetColor();
}
else if (content is FunctionCallContent functionCallContent)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[Client Tool Call - Name: {functionCallContent.Name}]");
Console.ResetColor();
}
else if (content is FunctionResultContent functionResultContent)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"[Client Tool Result: {functionResultContent.Result}]");
Console.ResetColor();
}
else if (content is ErrorContent errorContent)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[Error: {errorContent.Message}]");
Console.ResetColor();
}
}
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.WriteLine($"\nAn error occurred: {ex.Message}");
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
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.
ChatClient chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
// Map the AG-UI agent endpoint
app.MapAGUIServer("/", agent);
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7047;http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<PackageReference Include="AGUI.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using AGUI.Client;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100";
// Connect to the AG-UI server
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
// Create agent
ChatClientAgent baseAgent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
// Use default JSON serializer options
JsonSerializerOptions jsonSerializerOptions = JsonSerializerOptions.Default;
// Wrap the agent with ServerFunctionApprovalClientAgent
ServerFunctionApprovalClientAgent agent = new(baseAgent, jsonSerializerOptions);
List<ChatMessage> messages = [];
AgentSession? session = null;
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine("Ask a question (or type 'exit' to quit):");
Console.ResetColor();
string? input;
while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
messages.Add(new ChatMessage(ChatRole.User, input));
Console.WriteLine();
#pragma warning disable MEAI001
List<AIContent> approvalResponses = [];
do
{
approvalResponses.Clear();
List<AgentResponseUpdate> chatResponseUpdates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, cancellationToken: default))
{
chatResponseUpdates.Add(update);
foreach (AIContent content in update.Contents)
{
switch (content)
{
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent fcc:
DisplayApprovalRequest(approvalRequest, fcc);
Console.Write($"\nApprove '{fcc.Name}'? (yes/no): ");
string? userInput = Console.ReadLine();
bool approved = userInput?.ToUpperInvariant() is "YES" or "Y";
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
if (approvalRequest.AdditionalProperties != null)
{
approvalResponse.AdditionalProperties = [];
foreach (var kvp in approvalRequest.AdditionalProperties)
{
approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value;
}
}
approvalResponses.Add(approvalResponse);
break;
case TextContent textContent:
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(textContent.Text);
Console.ResetColor();
break;
case FunctionCallContent functionCall:
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"[Tool Call - Name: {functionCall.Name}]");
if (functionCall.Arguments is { } arguments)
{
Console.WriteLine($" Parameters: {JsonSerializer.Serialize(arguments)}");
}
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"[Tool Result: {functionResult.Result}]");
Console.ResetColor();
break;
case ErrorContent error:
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[Error: {error.Message}]");
Console.ResetColor();
break;
}
}
}
AgentResponse response = chatResponseUpdates.ToAgentResponse();
messages.AddRange(response.Messages);
foreach (AIContent approvalResponse in approvalResponses)
{
messages.Add(new ChatMessage(ChatRole.Tool, [approvalResponse]));
}
}
while (approvalResponses.Count > 0);
#pragma warning restore MEAI001
Console.WriteLine("\n");
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine("Ask another question (or type 'exit' to quit):");
Console.ResetColor();
}
#pragma warning disable MEAI001
static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, FunctionCallContent fcc)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine();
Console.WriteLine("============================================================");
Console.WriteLine("APPROVAL REQUIRED");
Console.WriteLine("============================================================");
Console.WriteLine($"Function: {fcc.Name}");
if (fcc.Arguments != null)
{
Console.WriteLine("Arguments:");
foreach (var arg in fcc.Arguments)
{
Console.WriteLine($" {arg.Key} = {arg.Value}");
}
}
Console.WriteLine("============================================================");
Console.ResetColor();
}
#pragma warning restore MEAI001
@@ -0,0 +1,265 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ServerFunctionApproval;
/// <summary>
/// A delegating agent that handles server function approval requests and responses.
/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent
/// and the server's request_approval tool call pattern.
/// </summary>
internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
public ServerFunctionApprovalClientAgent(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)
{
// Process and transform approval messages, creating a new message list
var processedMessages = ProcessOutgoingServerFunctionApprovals(messages.ToList(), this._jsonSerializerOptions);
// Run the inner agent and intercept any approval requests
await foreach (var update in this.InnerAgent.RunStreamingAsync(
processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return ProcessIncomingServerApprovalRequests(update, this._jsonSerializerOptions);
}
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only
private static FunctionResultContent ConvertApprovalResponseToToolResult(ToolApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions)
{
return new FunctionResultContent(
callId: approvalResponse.RequestId,
result: JsonSerializer.SerializeToElement(
new ApprovalResponse
{
ApprovalId = approvalResponse.RequestId,
Approved = approvalResponse.Approved
},
jsonOptions));
}
private static List<ChatMessage> CopyMessagesUpToIndex(List<ChatMessage> messages, int index)
{
var result = new List<ChatMessage>(index);
for (int i = 0; i < index; i++)
{
result.Add(messages[i]);
}
return result;
}
private static List<AIContent> CopyContentsUpToIndex(IList<AIContent> contents, int index)
{
var result = new List<AIContent>(index);
for (int i = 0; i < index; i++)
{
result.Add(contents[i]);
}
return result;
}
private static List<ChatMessage> ProcessOutgoingServerFunctionApprovals(
List<ChatMessage> messages,
JsonSerializerOptions jsonSerializerOptions)
{
List<ChatMessage>? result = null;
Dictionary<string, ToolApprovalRequestContent> approvalRequests = [];
for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{
var message = messages[messageIndex];
List<AIContent>? transformedContents = null;
// Process each content item in the message
HashSet<string> approvalCalls = [];
for (var contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++)
{
var content = message.Contents[contentIndex];
// Handle pending approval requests (transform to tool call)
if (content is ToolApprovalRequestContent approvalRequest &&
approvalRequest.AdditionalProperties?.TryGetValue("original_function", out var originalFunction) == true &&
originalFunction is FunctionCallContent original)
{
approvalRequests[approvalRequest.RequestId] = approvalRequest;
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
transformedContents.Add(original);
}
// Handle pending approval responses (transform to tool result)
else if (content is ToolApprovalResponseContent approvalResponse &&
approvalRequests.TryGetValue(approvalResponse.RequestId, out var correspondingRequest))
{
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
transformedContents.Add(ConvertApprovalResponseToToolResult(approvalResponse, jsonSerializerOptions));
approvalRequests.Remove(approvalResponse.RequestId);
correspondingRequest.AdditionalProperties?.Remove("original_function");
}
// Skip historical approval content
else if (content is FunctionCallContent { Name: "request_approval" } approvalCall)
{
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
approvalCalls.Add(approvalCall.CallId);
}
else if (content is FunctionResultContent functionResult &&
approvalCalls.Contains(functionResult.CallId))
{
transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex);
approvalCalls.Remove(functionResult.CallId);
}
else
{
transformedContents?.Add(content);
}
}
if (transformedContents?.Count == 0)
{
continue;
}
else if (transformedContents != null)
{
// We made changes to contents, so use transformedContents
var newMessage = new ChatMessage(message.Role, transformedContents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt,
RawRepresentation = message.RawRepresentation,
AdditionalProperties = message.AdditionalProperties
};
result ??= CopyMessagesUpToIndex(messages, messageIndex);
result.Add(newMessage);
}
else
{
// We're already copying messages, so copy this unchanged message too
result?.Add(message);
}
// If result is null, we haven't made any changes yet, so keep processing
}
return result ?? messages;
}
private static AgentResponseUpdate ProcessIncomingServerApprovalRequests(
AgentResponseUpdate update,
JsonSerializerOptions jsonSerializerOptions)
{
IList<AIContent>? updatedContents = null;
for (var i = 0; i < update.Contents.Count; i++)
{
var content = update.Contents[i];
if (content is FunctionCallContent { Name: "request_approval" } request)
{
updatedContents ??= [.. update.Contents];
// Serialize the function arguments as JsonElement
ApprovalRequest? approvalRequest;
if (request.Arguments?.TryGetValue("request", out var reqObj) == true &&
reqObj is JsonElement je)
{
approvalRequest = (ApprovalRequest?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest)));
}
else
{
approvalRequest = null;
}
if (approvalRequest == null)
{
throw new InvalidOperationException("Failed to deserialize approval request.");
}
var functionCallArgs = (Dictionary<string, object?>?)approvalRequest.FunctionArguments?
.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
var approvalRequestContent = new ToolApprovalRequestContent(
requestId: approvalRequest.ApprovalId,
new FunctionCallContent(
callId: approvalRequest.ApprovalId,
name: approvalRequest.FunctionName,
arguments: functionCallArgs));
approvalRequestContent.AdditionalProperties ??= [];
approvalRequestContent.AdditionalProperties["original_function"] = content;
updatedContents[i] = approvalRequestContent;
}
}
if (updatedContents is not null)
{
var chatUpdate = update.AsChatResponseUpdate();
return new AgentResponseUpdate(new ChatResponseUpdate()
{
Role = chatUpdate.Role,
Contents = updatedContents,
MessageId = chatUpdate.MessageId,
AuthorName = chatUpdate.AuthorName,
CreatedAt = chatUpdate.CreatedAt,
RawRepresentation = chatUpdate.RawRepresentation,
ResponseId = chatUpdate.ResponseId,
AdditionalProperties = chatUpdate.AdditionalProperties
})
{
AgentId = update.AgentId,
ContinuationToken = update.ContinuationToken,
};
}
return update;
}
}
#pragma warning restore MEAI001
namespace ServerFunctionApproval
{
public sealed class ApprovalRequest
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("function_name")]
public required string FunctionName { get; init; }
[JsonPropertyName("function_arguments")]
public JsonElement? FunctionArguments { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class ApprovalResponse
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("approved")]
public required bool Approved { get; init; }
}
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OpenAI.Chat;
using ServerFunctionApproval;
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(ApprovalJsonContext.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();
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.");
// Define approval-required tool
[Description("Approve the expense report.")]
static string ApproveExpenseReport(string expenseReportId)
{
return $"Expense report {expenseReportId} approved";
}
// Get JsonSerializerOptions
var jsonOptions = app.Services.GetRequiredService<IOptions<JsonOptions>>().Value;
// Create approval-required tool
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ApproveExpenseReport))];
#pragma warning restore MEAI001
// Create base 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.
ChatClient openAIChatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant in charge of approving expenses",
tools: tools);
// Wrap with ServerFunctionApprovalAgent
var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions);
app.MapAGUIServer("/", agent);
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5100",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7047;http://localhost:5100",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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,249 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ServerFunctionApproval;
/// <summary>
/// A delegating agent that handles function approval requests on the server side.
/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent
/// and the request_approval tool call pattern for client communication.
/// </summary>
internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
public ServerFunctionApprovalAgent(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)
{
// Process and transform incoming approval responses from client, creating a new message list
var processedMessages = ProcessIncomingFunctionApprovals(messages.ToList(), this._jsonSerializerOptions);
// Run the inner agent and intercept any approval requests
await foreach (var update in this.InnerAgent.RunStreamingAsync(
processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return ProcessOutgoingApprovalRequests(update, this._jsonSerializerOptions);
}
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only
private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions)
{
if (toolCall.Name != "request_approval" || toolCall.Arguments == null)
{
throw new InvalidOperationException("Invalid request_approval tool call");
}
var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) &&
reqObj is JsonElement argsElement &&
argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest &&
approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call");
return new ToolApprovalRequestContent(
requestId: request.ApprovalId,
new FunctionCallContent(
callId: request.ApprovalId,
name: request.FunctionName,
arguments: request.FunctionArguments));
}
private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions)
{
var approvalResponse = (result.Result is JsonElement je ?
(ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
result.Result is string str ?
(ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result");
return approval.CreateResponse(approvalResponse.Approved);
}
#pragma warning restore MEAI001
private static List<ChatMessage> CopyMessagesUpToIndex(List<ChatMessage> messages, int index)
{
var result = new List<ChatMessage>(index);
for (int i = 0; i < index; i++)
{
result.Add(messages[i]);
}
return result;
}
private static List<AIContent> CopyContentsUpToIndex(IList<AIContent> contents, int index)
{
var result = new List<AIContent>(index);
for (int i = 0; i < index; i++)
{
result.Add(contents[i]);
}
return result;
}
private static List<ChatMessage> ProcessIncomingFunctionApprovals(
List<ChatMessage> messages,
JsonSerializerOptions jsonSerializerOptions)
{
List<ChatMessage>? result = null;
// Track approval ID to original call ID mapping
_ = new Dictionary<string, string>();
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
Dictionary<string, ToolApprovalRequestContent> trackedRequestApprovalToolCalls = []; // Remote approvals
for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{
var message = messages[messageIndex];
List<AIContent>? transformedContents = null;
for (int j = 0; j < message.Contents.Count; j++)
{
var content = message.Contents[j];
if (content is FunctionCallContent { Name: "request_approval" } toolCall)
{
result ??= CopyMessagesUpToIndex(messages, messageIndex);
transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
var approvalRequest = ConvertToolCallToApprovalRequest(toolCall, jsonSerializerOptions);
transformedContents.Add(approvalRequest);
trackedRequestApprovalToolCalls[toolCall.CallId] = approvalRequest;
result.Add(new ChatMessage(message.Role, transformedContents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt,
RawRepresentation = message.RawRepresentation,
AdditionalProperties = message.AdditionalProperties
});
}
else if (content is FunctionResultContent toolResult &&
trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval))
{
result ??= CopyMessagesUpToIndex(messages, messageIndex);
transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
var approvalResponse = ConvertToolResultToApprovalResponse(toolResult, approval, jsonSerializerOptions);
transformedContents.Add(approvalResponse);
result.Add(new ChatMessage(message.Role, transformedContents)
{
AuthorName = message.AuthorName,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt,
RawRepresentation = message.RawRepresentation,
AdditionalProperties = message.AdditionalProperties
});
}
else
{
result?.Add(message);
}
}
}
#pragma warning restore MEAI001
return result ?? messages;
}
private static AgentResponseUpdate ProcessOutgoingApprovalRequests(
AgentResponseUpdate update,
JsonSerializerOptions jsonSerializerOptions)
{
IList<AIContent>? updatedContents = null;
for (var i = 0; i < update.Contents.Count; i++)
{
var content = update.Contents[i];
#pragma warning disable MEAI001 // Type is for evaluation purposes only
if (content is ToolApprovalRequestContent request && request.ToolCall is FunctionCallContent functionCall)
{
updatedContents ??= [.. update.Contents];
var approvalId = request.RequestId;
var approvalData = new ApprovalRequest
{
ApprovalId = approvalId,
FunctionName = functionCall.Name,
FunctionArguments = functionCall.Arguments,
Message = $"Approve execution of '{functionCall.Name}'?"
};
updatedContents[i] = new FunctionCallContent(
callId: approvalId,
name: "request_approval",
arguments: new Dictionary<string, object?> { ["request"] = approvalData });
}
#pragma warning restore MEAI001
}
if (updatedContents is not null)
{
var chatUpdate = update.AsChatResponseUpdate();
// Yield a tool call update that represents the approval request
return new AgentResponseUpdate(new ChatResponseUpdate()
{
Role = chatUpdate.Role,
Contents = updatedContents,
MessageId = chatUpdate.MessageId,
AuthorName = chatUpdate.AuthorName,
CreatedAt = chatUpdate.CreatedAt,
RawRepresentation = chatUpdate.RawRepresentation,
ResponseId = chatUpdate.ResponseId,
AdditionalProperties = chatUpdate.AdditionalProperties
})
{
AgentId = update.AgentId,
ContinuationToken = update.ContinuationToken
};
}
return update;
}
}
namespace ServerFunctionApproval
{
// Define approval models
public sealed class ApprovalRequest
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("function_name")]
public required string FunctionName { get; init; }
[JsonPropertyName("function_arguments")]
public IDictionary<string, object?>? FunctionArguments { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class ApprovalResponse
{
[JsonPropertyName("approval_id")]
public required string ApprovalId { get; init; }
[JsonPropertyName("approved")]
public required bool Approved { get; init; }
}
[JsonSerializable(typeof(ApprovalRequest))]
[JsonSerializable(typeof(ApprovalResponse))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
public sealed partial class ApprovalJsonContext : JsonSerializerContext;
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<PackageReference Include="AGUI.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,234 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using AGUI.Abstractions;
using AGUI.Client;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using RecipeClient;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
Console.WriteLine($"Connecting to AG-UI server at: {serverUrl}\n");
// Create the AG-UI client agent
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
AIAgent baseAgent = chatClient.AsAIAgent(
name: "recipe-client",
description: "AG-UI Recipe Client Agent");
// Wrap the base agent with state management
JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web)
{
TypeInfoResolver = RecipeSerializerContext.Default
};
StatefulAgent<AgentState> agent = new(baseAgent, jsonOptions, new AgentState());
AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful recipe assistant.")
];
try
{
while (true)
{
// Get user input
Console.Write("\nUser (:q to quit, :state to show state): ");
string? message = Console.ReadLine();
if (string.IsNullOrWhiteSpace(message))
{
Console.WriteLine("Request cannot be empty.");
continue;
}
if (message is ":q" or "quit")
{
break;
}
if (message.Equals(":state", StringComparison.OrdinalIgnoreCase))
{
DisplayState(agent.State.Recipe);
continue;
}
messages.Add(new ChatMessage(ChatRole.User, message));
// Stream the response
bool isFirstUpdate = true;
string? threadId = null;
bool stateReceived = false;
Console.WriteLine();
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
{
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
// First update indicates run started
if (isFirstUpdate)
{
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
// id is carried on the AG-UI RUN_STARTED event's raw representation.
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
Console.ResetColor();
isFirstUpdate = false;
}
// Display streaming content
foreach (AIContent content in update.Contents)
{
switch (content)
{
case TextContent textContent:
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(textContent.Text);
Console.ResetColor();
break;
case DataContent dataContent when dataContent.MediaType == "application/json":
// This is a state snapshot - the StatefulAgent has already updated the state
stateReceived = true;
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine("\n[State Snapshot Received]");
Console.ResetColor();
break;
case ErrorContent errorContent:
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[Error: {errorContent.Message}]");
Console.ResetColor();
break;
}
}
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
Console.ResetColor();
// Display final state if received
if (stateReceived)
{
DisplayState(agent.State.Recipe);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"\nAn error occurred: {ex.Message}");
}
static void DisplayState(RecipeState? state)
{
if (state == null)
{
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine("\n[No state available]");
Console.ResetColor();
return;
}
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine("\n" + new string('=', 60));
Console.WriteLine("CURRENT STATE");
Console.WriteLine(new string('=', 60));
Console.ResetColor();
if (!string.IsNullOrEmpty(state.Title))
{
Console.WriteLine("\nRecipe:");
Console.WriteLine($" Title: {state.Title}");
if (!string.IsNullOrEmpty(state.Cuisine))
{
Console.WriteLine($" Cuisine: {state.Cuisine}");
}
if (!string.IsNullOrEmpty(state.SkillLevel))
{
Console.WriteLine($" Skill Level: {state.SkillLevel}");
}
if (state.PrepTimeMinutes > 0)
{
Console.WriteLine($" Prep Time: {state.PrepTimeMinutes} minutes");
}
if (state.CookTimeMinutes > 0)
{
Console.WriteLine($" Cook Time: {state.CookTimeMinutes} minutes");
}
if (state.Ingredients.Count > 0)
{
Console.WriteLine("\n Ingredients:");
foreach (var ingredient in state.Ingredients)
{
Console.WriteLine($" - {ingredient}");
}
}
if (state.Steps.Count > 0)
{
Console.WriteLine("\n Steps:");
for (int i = 0; i < state.Steps.Count; i++)
{
Console.WriteLine($" {i + 1}. {state.Steps[i]}");
}
}
}
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine("\n" + new string('=', 60));
Console.ResetColor();
}
// State wrapper
internal sealed class AgentState
{
[JsonPropertyName("recipe")]
public RecipeState Recipe { get; set; } = new();
}
// Recipe state model
internal sealed class RecipeState
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("cuisine")]
public string Cuisine { get; set; } = string.Empty;
[JsonPropertyName("ingredients")]
public List<string> Ingredients { get; set; } = [];
[JsonPropertyName("steps")]
public List<string> Steps { get; set; } = [];
[JsonPropertyName("prep_time_minutes")]
public int PrepTimeMinutes { get; set; }
[JsonPropertyName("cook_time_minutes")]
public int CookTimeMinutes { get; set; }
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
}
// JSON serialization context
[JsonSerializable(typeof(AgentState))]
[JsonSerializable(typeof(RecipeState))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class RecipeSerializerContext : JsonSerializerContext;
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace RecipeClient;
/// <summary>
/// A delegating agent that manages client-side state and automatically attaches it to requests.
/// </summary>
/// <typeparam name="TState">The state type.</typeparam>
internal sealed class StatefulAgent<TState> : DelegatingAIAgent
where TState : class, new()
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Gets or sets the current state.
/// </summary>
public TState State { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="StatefulAgent{TState}"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to delegate to.</param>
/// <param name="jsonSerializerOptions">The JSON serializer options for state serialization.</param>
/// <param name="initialState">The initial state. If null, a new instance will be created.</param>
public StatefulAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions, TState? initialState = null)
: base(innerAgent)
{
this._jsonSerializerOptions = jsonSerializerOptions;
this.State = initialState ?? new TState();
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Add state to messages
List<ChatMessage> messagesWithState = [.. messages];
// Serialize the state using AgentState wrapper
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
this.State,
this._jsonSerializerOptions.GetTypeInfo(typeof(TState)));
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
messagesWithState.Add(stateMessage);
// Stream the response and update state when received
await foreach (AgentResponseUpdate update in this.InnerAgent.RunStreamingAsync(messagesWithState, session, options, cancellationToken))
{
// Check if this update contains a state snapshot
foreach (AIContent content in update.Contents)
{
if (content is DataContent dataContent && dataContent.MediaType == "application/json")
{
// Deserialize the state
if (JsonSerializer.Deserialize(
dataContent.Data.Span,
this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) is TState newState)
{
this.State = newState;
}
}
}
yield return update;
}
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.Options;
using OpenAI.Chat;
using RecipeAssistant;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default));
builder.Services.AddAGUIServer();
// Configure to listen on port 8888
builder.WebHost.UseUrls("http://localhost:8888");
// 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.");
// Get JsonSerializerOptions
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>().Value;
// Create base 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.
ChatClient chatClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent baseAgent = chatClient.AsAIAgent(
name: "RecipeAgent",
instructions: """
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
respond with a complete AgentState JSON object that includes:
- recipe.title: The recipe name
- recipe.cuisine: Type of cuisine (e.g., Italian, Mexican, Japanese)
- recipe.ingredients: Array of ingredient strings with quantities
- recipe.steps: Array of cooking instruction strings
- recipe.prep_time_minutes: Preparation time in minutes
- recipe.cook_time_minutes: Cooking time in minutes
- recipe.skill_level: One of "beginner", "intermediate", or "advanced"
Always include all fields in the response. Be creative and helpful.
""");
// Wrap with state management middleware
AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions);
// Map the AG-UI agent endpoint
app.MapAGUIServer("/", agent);
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7047;http://localhost:5253",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace RecipeAssistant;
// State wrapper
internal sealed class AgentState
{
[JsonPropertyName("recipe")]
public RecipeState Recipe { get; set; } = new();
}
// Recipe state model
internal sealed class RecipeState
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("cuisine")]
public string Cuisine { get; set; } = string.Empty;
[JsonPropertyName("ingredients")]
public List<string> Ingredients { get; set; } = [];
[JsonPropertyName("steps")]
public List<string> Steps { get; set; } = [];
[JsonPropertyName("prep_time_minutes")]
public int PrepTimeMinutes { get; set; }
[JsonPropertyName("cook_time_minutes")]
public int CookTimeMinutes { get; set; }
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
}
// JSON serialization context
[JsonSerializable(typeof(AgentState))]
[JsonSerializable(typeof(RecipeState))]
[JsonSerializable(typeof(System.Text.Json.JsonElement))]
internal sealed partial class RecipeSerializerContext : JsonSerializerContext;
@@ -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,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using AGUI.Abstractions;
using AGUI.Server;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace RecipeAssistant;
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)
{
// Check if the client sent state in the request
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions ||
!chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) ||
agentInput.State is not { ValueKind: JsonValueKind.Object } state)
{
// No state management requested, pass through to inner agent
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
// Check if state has properties (not empty {})
bool hasProperties = false;
foreach (JsonProperty _ in state.EnumerateObject())
{
hasProperties = true;
break;
}
if (!hasProperties)
{
// Empty state - treat as no state
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
// First run: Generate structured state update
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<AgentState>(
schemaName: "AgentState",
schemaDescription: "A response containing a recipe with title, skill level, cooking time, ingredients, and instructions");
// Add current state to the conversation - state is already a JsonElement
ChatMessage stateUpdateMessage = new(
ChatRole.System,
[
new TextContent("Here is the current state in JSON format:"),
new TextContent(JsonSerializer.Serialize(state, this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))),
new TextContent("The new state is:")
]);
var firstRunMessages = messages.Append(stateUpdateMessage);
// Collect all updates from first run
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();
// Try to deserialize the structured state response
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
// Serialize and emit as STATE_SNAPSHOT via DataContent
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
yield return new AgentResponseUpdate
{
Contents = [new DataContent(stateBytes, "application/json")]
};
}
else
{
yield break;
}
// Second run: Generate user-friendly summary
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? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (deserialized is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = deserialized;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}