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
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:
@@ -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();
|
||||
+23
@@ -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>
|
||||
+8
@@ -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;
|
||||
+23
@@ -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();
|
||||
+23
@@ -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
|
||||
+265
@@ -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();
|
||||
+23
@@ -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>
|
||||
+249
@@ -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();
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user