mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573aff4825 | ||
|
|
9b471cc479 | ||
|
|
afd8b7ecb4 | ||
|
|
327c304339 | ||
|
|
3f835c8118 | ||
|
|
0bf6d437d8 | ||
|
|
d701e796cb | ||
|
|
8855bfb065 | ||
|
|
94a5ba3448 | ||
|
|
33f84f9ed2 | ||
|
|
e2282ebe42 | ||
|
|
b8a55dccb4 | ||
|
|
14aee7e334 | ||
|
|
b03a4fb95e | ||
|
|
77d882e2b4 | ||
|
|
6ca907f23f | ||
|
|
5e38c63455 | ||
|
|
bb8ef466de | ||
|
|
54db13c22f | ||
|
|
51b32ed1ac | ||
|
|
d81b579111 | ||
|
|
35a8565495 | ||
|
|
0c862e97a6 | ||
|
|
bbde248839 | ||
|
|
552f7c781d | ||
|
|
2499262f30 | ||
|
|
f415959d33 |
@@ -12,6 +12,8 @@ ignorePatterns:
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
- pattern: "https:\/\/localhost"
|
||||
- pattern: "https:\/\/127.0.0.1"
|
||||
- pattern: "0001-spec.md"
|
||||
- pattern: "0001-madr-architecture-decisions.md"
|
||||
- pattern: "https://api.powerplatform.com/.default"
|
||||
|
||||
@@ -1052,7 +1052,7 @@ AgentThread thread = agent.GetNewThread();
|
||||
|
||||
**Add Agent Framework Packages:**
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.Agents.AI.AzureAI" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.AzureAI.Persistent" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
```
|
||||
</configuration_changes>
|
||||
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
- name: Setup dotnet
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.10", "3.14"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
# TODO(ekzhu): re-enable macos-latest when this is fixed: https://github.com/actions/runner-images/issues/11881
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
env:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
# todo: add macos-latest when problems are resolved
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
env:
|
||||
|
||||
+9
-1
@@ -203,4 +203,12 @@ agents.md
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
WARP.md
|
||||
WARP.md
|
||||
|
||||
# Frontend
|
||||
**/frontend/node_modules/
|
||||
**/frontend/.vite/
|
||||
**/frontend/dist/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: javiercn
|
||||
date: 2025-10-29
|
||||
deciders: javiercn, DeagleGross, moonbox3, markwallace-microsoft
|
||||
consulted: Agent Framework team
|
||||
informed: .NET community
|
||||
---
|
||||
|
||||
# AG-UI Protocol Support for .NET Agent Framework
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The .NET Agent Framework needed a standardized way to enable communication between AI agents and user-facing applications with support for streaming, real-time updates, and bidirectional communication. Without AG-UI protocol support, .NET agents could not interoperate with the growing ecosystem of AG-UI-compatible frontends and agent frameworks (LangGraph, CrewAI, Pydantic AI, etc.), limiting the framework's adoption and utility.
|
||||
|
||||
The AG-UI (Agent-User Interaction) protocol is an open, lightweight, event-based protocol that addresses key challenges in agentic applications including streaming support for long-running agents, event-driven architecture for nondeterministic behavior, and protocol interoperability that complements MCP (tool/context) and A2A (agent-to-agent) protocols.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Need for streaming communication between agents and client applications
|
||||
- Requirement for protocol interoperability with other AI frameworks
|
||||
- Support for long-running, multi-turn conversation sessions
|
||||
- Real-time UI updates for nondeterministic agent behavior
|
||||
- Standardized approach to agent-to-UI communication
|
||||
- Framework abstraction to protect consumers from protocol changes
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Implement AG-UI event types as public API surface** - Expose AG-UI event models directly to consumers
|
||||
2. **Use custom AIContent types for lifecycle events** - Create new content types (RunStartedContent, RunFinishedContent, RunErrorContent)
|
||||
3. **Current approach** - Internal event types with framework-native abstractions
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Current approach with internal event types and framework-native abstractions", because it:
|
||||
|
||||
- Protects consumers from protocol changes by keeping AG-UI events internal
|
||||
- Maintains framework abstractions through conversion at boundaries
|
||||
- Uses existing framework types (AgentRunResponseUpdate, ChatMessage) for public API
|
||||
- Focuses on core text streaming functionality
|
||||
- Leverages existing properties (ConversationId, ResponseId, ErrorContent) instead of custom types
|
||||
- Provides bidirectional client and server support
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**In Scope:**
|
||||
1. **Client-side AG-UI consumption** (`Microsoft.Agents.AI.AGUI` package)
|
||||
- `AGUIAgent` class for connecting to remote AG-UI servers
|
||||
- `AGUIAgentThread` for managing conversation threads
|
||||
- HTTP/SSE streaming support
|
||||
- Event-to-framework type conversion
|
||||
|
||||
2. **Server-side AG-UI hosting** (`Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` package)
|
||||
- `MapAGUIAgent` extension method for ASP.NET Core
|
||||
- Server-Sent Events (SSE) response formatting
|
||||
- Framework-to-event type conversion
|
||||
- Agent factory pattern for per-request instantiation
|
||||
|
||||
3. **Text streaming events**
|
||||
- Lifecycle events: `RunStarted`, `RunFinished`, `RunError`
|
||||
- Text message events: `TextMessageStart`, `TextMessageContent`, `TextMessageEnd`
|
||||
- Thread and run ID management via `ConversationId` and `ResponseId`
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Event Models as Internal Types** - AG-UI event types are internal with conversion via extension methods; public API uses the existing types in Microsoft.Extensions.AI as those are the abstractions people are familiar with
|
||||
|
||||
2. **No Custom Content Types** - Run lifecycle communicated through existing `ChatResponseUpdate` properties (`ConversationId`, `ResponseId`) and standard `ErrorContent` type
|
||||
|
||||
3. **Agent Factory Pattern** - `MapAGUIAgent` uses factory function `(messages) => AIAgent` to allow request-specific agent configuration supporting multi-tenancy
|
||||
|
||||
4. **Bidirectional Conversion Architecture** - Symmetric conversion logic in shared namespace compiled into both packages for server (`AgentRunResponseUpdate` → AG-UI events) and client (AG-UI events → `AgentRunResponseUpdate`)
|
||||
|
||||
5. **Thread Management** - `AGUIAgentThread` stores only `ThreadId` with thread ID communicated via `ConversationId`; applications manage persistence for parity with other implementations and to be compliant with the protocol. Future extensions will support having the server manage the conversation.
|
||||
|
||||
6. **Custom JSON Converter** - Uses custom polymorphic deserialization via `BaseEventJsonConverter` instead of built-in System.Text.Json support to handle AG-UI protocol's flexible discriminator positioning
|
||||
|
||||
### Consequences
|
||||
|
||||
**Positive:**
|
||||
- .NET developers can consume AG-UI servers from any framework
|
||||
- .NET agents accessible from any AG-UI-compatible client
|
||||
- Standardized streaming communication patterns
|
||||
- Protected from protocol changes through internal implementation
|
||||
- Symmetric conversion logic between client and server
|
||||
- Framework-native public API surface
|
||||
|
||||
**Negative:**
|
||||
- Custom JSON converter required (internal implementation detail)
|
||||
- Shared code uses preprocessor directives (`#if ASPNETCORE`)
|
||||
- Additional abstraction layer between protocol and public API
|
||||
|
||||
**Neutral:**
|
||||
- Initial implementation focused on text streaming
|
||||
- Applications responsible for thread persistence
|
||||
@@ -30,6 +30,7 @@
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="9.0.10" />
|
||||
@@ -52,6 +53,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.10.0-preview.1.25513.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
@@ -97,7 +99,7 @@
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageVersion Include="Moq" Version="[4.18.4]" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.66.0" />
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj" />
|
||||
<Project Path="samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AGUIClientServer/">
|
||||
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
|
||||
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
</Folder>
|
||||
@@ -62,6 +66,10 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DevUI/">
|
||||
<File Path="samples/GettingStarted/DevUI/README.md" />
|
||||
<Project Path="samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
|
||||
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
|
||||
@@ -143,6 +151,7 @@
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/Catalog/">
|
||||
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
@@ -271,10 +280,13 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
@@ -288,6 +300,7 @@
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
@@ -296,9 +309,11 @@
|
||||
</Folder>
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251104.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251105.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251105.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251105.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
|
||||
// and display streaming updates including conversation/response metadata, text content, and errors.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Reflection;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AGUIClient;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
// Create root command with options
|
||||
RootCommand rootCommand = new("AGUIClient");
|
||||
rootCommand.SetAction((_, ct) => HandleCommandsAsync(ct));
|
||||
|
||||
// Run the command
|
||||
return await rootCommand.Parse(args).InvokeAsync();
|
||||
}
|
||||
|
||||
private static async Task HandleCommandsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Set up the logging
|
||||
using ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
builder.SetMinimumLevel(LogLevel.Information);
|
||||
});
|
||||
ILogger logger = loggerFactory.CreateLogger("AGUIClient");
|
||||
|
||||
// Retrieve configuration settings
|
||||
IConfigurationRoot configRoot = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.Build();
|
||||
|
||||
string serverUrl = configRoot["AGUI_SERVER_URL"] ?? "http://localhost:5100";
|
||||
|
||||
logger.LogInformation("Connecting to AG-UI server at: {ServerUrl}", serverUrl);
|
||||
|
||||
// Create the AG-UI client agent
|
||||
using HttpClient httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIAgent agent = new(
|
||||
id: "agui-client",
|
||||
description: "AG-UI Client Agent",
|
||||
httpClient: httpClient,
|
||||
endpoint: serverUrl);
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
List<ChatMessage> messages = [new(ChatRole.System, "You are a helpful assistant.")];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// Get user message
|
||||
Console.Write("\nUser (:q or quit to exit): ");
|
||||
string? message = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
Console.WriteLine("Request cannot be empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message is ":q" or "quit")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
messages.Add(new(ChatRole.User, message));
|
||||
|
||||
// Call RunStreamingAsync to get streaming updates
|
||||
bool isFirstUpdate = true;
|
||||
string? threadId = null;
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread, cancellationToken: cancellationToken))
|
||||
{
|
||||
// Use AsChatResponseUpdate to access ChatResponseUpdate properties
|
||||
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
|
||||
if (chatUpdate.ConversationId != null)
|
||||
{
|
||||
threadId = chatUpdate.ConversationId;
|
||||
}
|
||||
|
||||
// Display run started information from the first update
|
||||
if (isFirstUpdate && threadId != null && update.ResponseId != null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {update.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
|
||||
// Display different content types with appropriate formatting
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write(textContent.Text);
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case ErrorContent errorContent:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
string code = errorContent.AdditionalProperties?["Code"] as string ?? "Unknown";
|
||||
Console.WriteLine($"\n[Error - Code: {code}, Message: {errorContent.Message}]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.Clear();
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogInformation("AGUIClient operation was canceled.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not ThreadAbortException and not AccessViolationException)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while running the AGUIClient");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# AG-UI Client
|
||||
|
||||
This is a console application that demonstrates how to connect to an AG-UI server and interact with remote agents using the AG-UI protocol.
|
||||
|
||||
## Features
|
||||
|
||||
- Connects to an AG-UI server endpoint
|
||||
- Displays streaming updates with color-coded output:
|
||||
- **Yellow**: Run started notifications
|
||||
- **Cyan**: Agent text responses (streamed)
|
||||
- **Green**: Run finished notifications
|
||||
- **Red**: Error messages (if any)
|
||||
- Interactive prompt loop for sending messages
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variable to specify the AG-UI server URL:
|
||||
|
||||
```powershell
|
||||
$env:AGUI_SERVER_URL="http://localhost:5100"
|
||||
```
|
||||
|
||||
If not set, the default is `http://localhost:5100`.
|
||||
|
||||
## Running the Client
|
||||
|
||||
1. Make sure the AG-UI server is running
|
||||
2. Run the client:
|
||||
```bash
|
||||
cd AGUIClient
|
||||
dotnet run
|
||||
```
|
||||
3. Enter your messages and observe the streaming updates
|
||||
4. Type `:q` or `quit` to exit
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
@host = http://localhost:5100
|
||||
|
||||
### Send a message to the AG-UI agent
|
||||
POST {{host}}/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"threadId": "thread_123",
|
||||
"runId": "run_456",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
],
|
||||
"context": {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
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
|
||||
var agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(name: "AGUIAssistant");
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,202 @@
|
||||
# AG-UI Client and Server Sample
|
||||
|
||||
This sample demonstrates how to use the AG-UI (Agent UI) protocol to enable communication between a client application and a remote agent server. The AG-UI protocol provides a standardized way for clients to interact with AI agents.
|
||||
|
||||
## Overview
|
||||
|
||||
The demonstration has two components:
|
||||
|
||||
1. **AGUIServer** - An ASP.NET Core web server that hosts an AI agent and exposes it via the AG-UI protocol
|
||||
2. **AGUIClient** - A console application that connects to the AG-UI server and displays streaming updates
|
||||
|
||||
> **Warning**
|
||||
> The AG-UI protocol is still under development and changing.
|
||||
> We will try to keep these samples updated as the protocol evolves.
|
||||
|
||||
## Configuring Environment Variables
|
||||
|
||||
Configure the required Azure OpenAI environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="<<your-model-endpoint>>"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini"
|
||||
```
|
||||
|
||||
> **Note:** This sample uses `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, Visual Studio, or environment variables).
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Step 1: Start the AG-UI Server
|
||||
|
||||
```bash
|
||||
cd AGUIServer
|
||||
dotnet build
|
||||
dotnet run --urls "http://localhost:5100"
|
||||
```
|
||||
|
||||
The server will start and listen on `http://localhost:5100`.
|
||||
|
||||
### Step 2: Testing with the REST Client (Optional)
|
||||
|
||||
Before running the client, you can test the server using the included `.http` file:
|
||||
|
||||
1. Open [./AGUIServer/AGUIServer.http](./AGUIServer/AGUIServer.http) in Visual Studio or VS Code with the REST Client extension
|
||||
2. Send a test request to verify the server is working
|
||||
3. Observe the server-sent events stream in the response
|
||||
|
||||
Sample request:
|
||||
```http
|
||||
POST http://localhost:5100/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"threadId": "thread_123",
|
||||
"runId": "run_456",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
],
|
||||
"context": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Run the AG-UI Client
|
||||
|
||||
In a new terminal window:
|
||||
|
||||
```bash
|
||||
cd AGUIClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Optionally, configure a different server URL:
|
||||
|
||||
```powershell
|
||||
$env:AGUI_SERVER_URL="http://localhost:5100"
|
||||
```
|
||||
|
||||
### Step 4: Interact with the Agent
|
||||
|
||||
1. The client will connect to the AG-UI server
|
||||
2. Enter your message at the prompt
|
||||
3. Observe the streaming updates with color-coded output:
|
||||
- **Yellow**: Run started notification showing thread and run IDs
|
||||
- **Cyan**: Agent's text response (streamed character by character)
|
||||
- **Green**: Run finished notification
|
||||
- **Red**: Error messages (if any occur)
|
||||
4. Type `:q` or `quit` to exit
|
||||
|
||||
## Sample Output
|
||||
|
||||
```
|
||||
AGUIClient> dotnet run
|
||||
info: AGUIClient[0]
|
||||
Connecting to AG-UI server at: http://localhost:5100
|
||||
|
||||
User (:q or quit to exit): What is the capital of France?
|
||||
|
||||
[Run Started - Thread: thread_abc123, Run: run_xyz789]
|
||||
The capital of France is Paris. It is known for its rich history, culture, and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
|
||||
[Run Finished - Thread: thread_abc123, Run: run_xyz789]
|
||||
|
||||
User (:q or quit to exit): Tell me a fun fact about space
|
||||
|
||||
[Run Started - Thread: thread_abc123, Run: run_def456]
|
||||
Here's a fun fact: A day on Venus is longer than its year! Venus takes about 243 Earth days to rotate once on its axis, but only about 225 Earth days to orbit the Sun.
|
||||
[Run Finished - Thread: thread_abc123, Run: run_def456]
|
||||
|
||||
User (:q or quit to exit): :q
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Server Side
|
||||
|
||||
The `AGUIServer` uses the `MapAGUI` extension method to expose an agent through the AG-UI protocol:
|
||||
|
||||
```csharp
|
||||
AIAgent agent = new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
.CreateAIAgent(
|
||||
instructions: "You are a helpful assistant.",
|
||||
name: "AGUIAssistant");
|
||||
|
||||
app.MapAGUI("/", agent);
|
||||
```
|
||||
|
||||
This automatically handles:
|
||||
- HTTP POST requests with message payloads
|
||||
- Converting agent responses to AG-UI event streams
|
||||
- Server-sent events (SSE) formatting
|
||||
- Thread and run management
|
||||
|
||||
### Client Side
|
||||
|
||||
The `AGUIClient` uses the `AGUIAgent` class to connect to the remote server:
|
||||
|
||||
```csharp
|
||||
AGUIAgent agent = new(
|
||||
id: "agui-client",
|
||||
description: "AG-UI Client Agent",
|
||||
messages: [],
|
||||
httpClient: httpClient,
|
||||
endpoint: serverUrl);
|
||||
|
||||
bool isFirstUpdate = true;
|
||||
AgentRunResponseUpdate? currentUpdate = null;
|
||||
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread))
|
||||
{
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
Console.WriteLine($"[Run Started - Thread: {update.ConversationId}, Run: {update.ResponseId}]");
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
|
||||
currentUpdate = update;
|
||||
|
||||
foreach (AIContent content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
// Display streaming text
|
||||
Console.Write(textContent.Text);
|
||||
break;
|
||||
case ErrorContent errorContent:
|
||||
// Display error notification
|
||||
Console.WriteLine($"[Error: {errorContent.Message}]");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last update indicates run finished
|
||||
if (currentUpdate != null)
|
||||
{
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {currentUpdate.ConversationId}, Run: {currentUpdate.ResponseId}]");
|
||||
}
|
||||
```
|
||||
|
||||
The `RunStreamingAsync` method:
|
||||
1. Sends messages to the server via HTTP POST
|
||||
2. Receives server-sent events (SSE) stream
|
||||
3. Parses events into `AgentRunResponseUpdate` objects
|
||||
4. Yields updates as they arrive for real-time display
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Thread**: Represents a conversation context that persists across multiple runs (accessed via `ConversationId` property)
|
||||
- **Run**: A single execution of the agent for a given set of messages (identified by `ResponseId` property)
|
||||
- **AgentRunResponseUpdate**: Contains the response data with:
|
||||
- `ResponseId`: The unique run identifier
|
||||
- `ConversationId`: The thread/conversation identifier
|
||||
- `Contents`: Collection of content items (TextContent, ErrorContent, etc.)
|
||||
- **Run Lifecycle**:
|
||||
- The **first** `AgentRunResponseUpdate` in a run indicates the run has started
|
||||
- Subsequent updates contain streaming content as the agent processes
|
||||
- The **last** `AgentRunResponseUpdate` in a run indicates the run has finished
|
||||
- If an error occurs, the update will contain `ErrorContent`
|
||||
@@ -20,14 +20,14 @@ builder.Services.AddProblemDetails();
|
||||
// Configure the chat model and our agent.
|
||||
builder.AddKeyedChatClient("chat-model");
|
||||
|
||||
builder.AddAIAgent(
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
description: "An agent that speaks like a pirate.",
|
||||
chatClientServiceKey: "chat-model")
|
||||
.WithInMemoryThreadStore();
|
||||
|
||||
builder.AddAIAgent("knights-and-knaves", (sp, key) =>
|
||||
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
|
||||
@@ -80,6 +80,8 @@ var literatureAgent = builder.AddAIAgent("literator",
|
||||
|
||||
builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
|
||||
builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
|
||||
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -104,8 +106,8 @@ app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", age
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
|
||||
app.MapOpenAIChatCompletions("pirate");
|
||||
app.MapOpenAIChatCompletions("knights-and-knaves");
|
||||
app.MapOpenAIChatCompletions(pirateAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
|
||||
|
||||
// Map the agents HTTP endpoints
|
||||
app.MapAgentDiscovery("/agents");
|
||||
|
||||
@@ -28,7 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-3
@@ -63,9 +63,7 @@ AIAgent agent = azureOpenAIClient
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
+1
-3
@@ -72,9 +72,7 @@ AIAgent agent = azureOpenAIClient
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -28,9 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
@@ -33,9 +33,9 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined
|
||||
// If each thread should have its own Mem0 scope, you can create a new id per thread here:
|
||||
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() })
|
||||
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ThreadId = Guid.NewGuid().ToString() })
|
||||
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
|
||||
? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
|
||||
? new Mem0Provider(mem0HttpClient, new Mem0ProviderScope() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
|
||||
// For cases where we are restoring from serialized state:
|
||||
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions)
|
||||
});
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DevUI_Step01_BasicUsage</RootNamespace>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates basic usage of the DevUI in an ASP.NET Core application with AI agents.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace DevUI_Step01_BasicUsage;
|
||||
|
||||
/// <summary>
|
||||
/// Sample demonstrating basic usage of the DevUI in an ASP.NET Core application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sample shows how to:
|
||||
/// 1. Set up Azure OpenAI as the chat client
|
||||
/// 2. Register agents and workflows using the hosting packages
|
||||
/// 3. Map the DevUI endpoint which automatically configures the middleware
|
||||
/// 4. Map the dynamic OpenAI Responses API for Python DevUI compatibility
|
||||
/// 5. Access the DevUI in a web browser
|
||||
///
|
||||
/// The DevUI provides an interactive web interface for testing and debugging AI agents.
|
||||
/// DevUI assets are served from embedded resources within the assembly.
|
||||
/// Simply call MapDevUI() to set up everything needed.
|
||||
///
|
||||
/// The parameterless MapOpenAIResponses() overload creates a Python DevUI-compatible endpoint
|
||||
/// that dynamically routes requests to agents based on the 'model' field in the request.
|
||||
/// </remarks>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point that starts an ASP.NET Core web server with the DevUI.
|
||||
/// </summary>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-4o-mini";
|
||||
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register sample agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately.");
|
||||
builder.AddAIAgent("poet", "You are a creative poet. Respond to all requests with beautiful poetry.");
|
||||
builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples.");
|
||||
|
||||
// Register sample workflows
|
||||
var assistantBuilder = builder.AddAIAgent("workflow-assistant", "You are a helpful assistant in a workflow.");
|
||||
var reviewerBuilder = builder.AddAIAgent("workflow-reviewer", "You are a reviewer. Review and critique the previous response.");
|
||||
builder.AddSequentialWorkflow(
|
||||
"review-workflow",
|
||||
[assistantBuilder, reviewerBuilder])
|
||||
.AddAsAIAgent();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapDevUI();
|
||||
}
|
||||
|
||||
Console.WriteLine("DevUI is available at: https://localhost:50516/devui");
|
||||
Console.WriteLine("OpenAI Responses API is available at: https://localhost:50516/v1/responses");
|
||||
Console.WriteLine("Press Ctrl+C to stop the server.");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# DevUI Step 01 - Basic Usage
|
||||
|
||||
This sample demonstrates how to add the DevUI to an ASP.NET Core application with AI agents.
|
||||
|
||||
## What is DevUI?
|
||||
|
||||
The DevUI provides an interactive web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
- `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL (required)
|
||||
- `AZURE_OPENAI_DEPLOYMENT_NAME` - Your deployment name (defaults to "gpt-4o-mini")
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. Set your Azure OpenAI credentials as environment variables
|
||||
2. Run the application:
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
3. Open your browser to https://localhost:50516/devui
|
||||
4. Select an agent or workflow from the dropdown and start chatting!
|
||||
|
||||
## Sample Agents and Workflows
|
||||
|
||||
This sample includes:
|
||||
|
||||
**Agents:**
|
||||
- **assistant** - A helpful assistant
|
||||
- **poet** - A creative poet
|
||||
- **coder** - An expert programmer
|
||||
|
||||
**Workflows:**
|
||||
- **review-workflow** - A sequential workflow that generates a response and then reviews it
|
||||
|
||||
## Adding DevUI to Your Own Project
|
||||
|
||||
To add DevUI to your ASP.NET Core application:
|
||||
|
||||
1. Add the DevUI package and hosting packages:
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DevUI
|
||||
dotnet add package Microsoft.Agents.AI.Hosting
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
|
||||
```
|
||||
|
||||
2. Register your agents and workflows:
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up your chat client
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
// Register workflows
|
||||
var agent1Builder = builder.AddAIAgent("workflow-agent1", "You are agent 1.");
|
||||
var agent2Builder = builder.AddAIAgent("workflow-agent2", "You are agent 2.");
|
||||
builder.AddSequentialWorkflow("my-workflow", [agent1Builder, agent2Builder])
|
||||
.AddAsAIAgent();
|
||||
```
|
||||
|
||||
3. Add DevUI services and map the endpoint:
|
||||
```csharp
|
||||
builder.AddDevUI();
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
// Add required endpoints
|
||||
app.MapEntities();
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
4. Navigate to `/devui` in your browser
|
||||
@@ -0,0 +1,57 @@
|
||||
# DevUI Samples
|
||||
|
||||
This folder contains samples demonstrating how to use the DevUI in ASP.NET Core applications.
|
||||
|
||||
## What is DevUI?
|
||||
|
||||
The DevUI provides an interactive web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Samples
|
||||
|
||||
### [DevUI_Step01_BasicUsage](./DevUI_Step01_BasicUsage)
|
||||
|
||||
Shows how to add DevUI to an ASP.NET Core application with multiple agents and workflows.
|
||||
|
||||
**Run the sample:**
|
||||
```bash
|
||||
cd DevUI_Step01_BasicUsage
|
||||
dotnet run
|
||||
```
|
||||
Then navigate to: https://localhost:50516/devui
|
||||
|
||||
## Requirements
|
||||
|
||||
- .NET 8.0 or later
|
||||
- ASP.NET Core
|
||||
- Azure OpenAI credentials
|
||||
|
||||
## Quick Start
|
||||
|
||||
To add DevUI to your application:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Set up the chat client
|
||||
builder.Services.AddChatClient(chatClient);
|
||||
|
||||
// Register your agents
|
||||
builder.AddAIAgent("my-agent", "You are a helpful assistant.");
|
||||
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map the DevUI endpoint
|
||||
app.MapDevUI();
|
||||
|
||||
// Add required endpoints
|
||||
app.MapEntities();
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
Then navigate to `/devui` in your browser.
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ internal static class WorkflowFactory
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
|
||||
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -52,8 +52,8 @@ public static class Program
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
var workflow = new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [physicist, chemist])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [physicist, chemist])
|
||||
.AddFanOutEdge(startExecutor, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ public static class Program
|
||||
|
||||
// Step 4: Build the concurrent workflow with fan-out/fan-in pattern
|
||||
return new WorkflowBuilder(splitter)
|
||||
.AddFanOutEdge(splitter, targets: [.. mappers]) // Split -> many mappers
|
||||
.AddFanInEdge(shuffler, sources: [.. mappers]) // All mappers -> shuffle
|
||||
.AddFanOutEdge(shuffler, targets: [.. reducers]) // Shuffle -> many reducers
|
||||
.AddFanInEdge(completion, sources: [.. reducers]) // All reducers -> completion
|
||||
.AddFanOutEdge(splitter, [.. mappers]) // Split -> many mappers
|
||||
.AddFanInEdge([.. mappers], shuffler) // All mappers -> shuffle
|
||||
.AddFanOutEdge(shuffler, [.. reducers]) // Shuffle -> many reducers
|
||||
.AddFanInEdge([.. reducers], completion) // All reducers -> completion
|
||||
.WithOutputFrom(completion)
|
||||
.Build();
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+3
-3
@@ -60,13 +60,13 @@ public static class Program
|
||||
WorkflowBuilder builder = new(emailAnalysisExecutor);
|
||||
builder.AddFanOutEdge(
|
||||
emailAnalysisExecutor,
|
||||
targets: [
|
||||
[
|
||||
handleSpamExecutor,
|
||||
emailAssistantExecutor,
|
||||
emailSummaryExecutor,
|
||||
handleUncertainExecutor,
|
||||
],
|
||||
partitioner: GetPartitioner()
|
||||
GetTargetAssigner()
|
||||
)
|
||||
// After the email assistant writes a response, it will be sent to the send email executor
|
||||
.AddEdge(emailAssistantExecutor, sendEmailExecutor)
|
||||
@@ -105,7 +105,7 @@ public static class Program
|
||||
/// Creates a partitioner for routing messages based on the analysis result.
|
||||
/// </summary>
|
||||
/// <returns>A function that takes an analysis result and returns the target partitions.</returns>
|
||||
private static Func<AnalysisResult?, int, IEnumerable<int>> GetPartitioner()
|
||||
private static Func<AnalysisResult?, int, IEnumerable<int>> GetTargetAssigner()
|
||||
{
|
||||
return (analysisResult, targetCount) =>
|
||||
{
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ internal static class WorkflowHelper
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
|
||||
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th
|
||||
| [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow |
|
||||
| [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors |
|
||||
| [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling |
|
||||
| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops |
|
||||
|
||||
Once completed, please proceed to other samples listed below.
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ public static class Program
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
var workflow = new WorkflowBuilder(fileRead)
|
||||
.AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount])
|
||||
.AddFanInEdge(aggregate, sources: [wordCount, paragraphCount])
|
||||
.AddFanOutEdge(fileRead, [wordCount, paragraphCount])
|
||||
.AddFanInEdge([wordCount, paragraphCount], aggregate)
|
||||
.WithOutputFrom(aggregate)
|
||||
.Build();
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>WriterCriticWorkflow</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WriterCriticWorkflow;
|
||||
|
||||
/// <summary>
|
||||
/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents.
|
||||
///
|
||||
/// The workflow implements a content creation and review loop that:
|
||||
/// 1. Writer creates initial content based on the user's request
|
||||
/// 2. Critic reviews the content and provides feedback using structured output
|
||||
/// 3. If approved: Summary executor presents the final content
|
||||
/// 4. If rejected: Writer revises based on feedback (loops back)
|
||||
/// 5. Continues until approval or max iterations (3) is reached
|
||||
///
|
||||
/// This pattern is useful when you need:
|
||||
/// - Iterative content improvement through feedback loops
|
||||
/// - Quality gates with reviewer approval
|
||||
/// - Maximum iteration limits to prevent infinite loops
|
||||
/// - Conditional workflow routing based on agent decisions
|
||||
/// - Structured output for reliable decision-making
|
||||
///
|
||||
/// Key Learning: Workflows can implement loops with conditional edges, shared state,
|
||||
/// and structured output for robust agent decision-making.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - Previous foundational samples should be completed first.
|
||||
/// - An Azure OpenAI chat completion deployment must be configured.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
public const int MaxIterations = 3;
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n");
|
||||
Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n");
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Create executors for content creation and review
|
||||
WriterExecutor writer = new(chatClient);
|
||||
CriticExecutor critic = new(chatClient);
|
||||
SummaryExecutor summary = new(chatClient);
|
||||
|
||||
// Build the workflow with conditional routing based on critic's decision
|
||||
WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer)
|
||||
.AddEdge(writer, critic)
|
||||
.AddSwitch(critic, sw => sw
|
||||
.AddCase<CriticDecision>(cd => cd?.Approved == true, summary)
|
||||
.AddCase<CriticDecision>(cd => cd?.Approved == false, writer))
|
||||
.WithOutputFrom(summary);
|
||||
|
||||
// Execute the workflow with a sample task
|
||||
// The workflow loops back to Writer if content is rejected,
|
||||
// or proceeds to Summary if approved. State tracking ensures we don't loop forever.
|
||||
Console.WriteLine(new string('=', 80));
|
||||
Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)");
|
||||
Console.WriteLine(new string('=', 80) + "\n");
|
||||
|
||||
const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging.";
|
||||
|
||||
Workflow workflow = workflowBuilder.Build();
|
||||
await ExecuteWorkflowAsync(workflow, InitialTask);
|
||||
|
||||
Console.WriteLine("\nâś… Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n");
|
||||
Console.WriteLine("Key Concepts Demonstrated:");
|
||||
Console.WriteLine(" âś“ Iterative refinement loop with conditional routing");
|
||||
Console.WriteLine(" âś“ Shared workflow state for iteration tracking");
|
||||
Console.WriteLine($" âś“ Max iteration cap ({MaxIterations}) for safety");
|
||||
Console.WriteLine(" âś“ Multiple message handlers in a single executor");
|
||||
Console.WriteLine(" âś“ Streaming support with structured output\n");
|
||||
}
|
||||
|
||||
private static async Task ExecuteWorkflowAsync(Workflow workflow, string input)
|
||||
{
|
||||
// Execute in streaming mode to see real-time progress
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync<string>(workflow, input);
|
||||
|
||||
// Watch the workflow events
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case AgentRunUpdateEvent agentUpdate:
|
||||
// Stream agent output in real-time
|
||||
if (!string.IsNullOrEmpty(agentUpdate.Update.Text))
|
||||
{
|
||||
Console.Write(agentUpdate.Update.Text);
|
||||
}
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent output:
|
||||
Console.WriteLine("\n\n" + new string('=', 80));
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("âś… FINAL APPROVED CONTENT");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(new string('=', 80));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(output.Data);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(new string('=', 80));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Shared State for Iteration Tracking
|
||||
// ====================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current iteration and conversation history across workflow executions.
|
||||
/// </summary>
|
||||
internal sealed class FlowState
|
||||
{
|
||||
public int Iteration { get; set; } = 1;
|
||||
public List<ChatMessage> History { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for accessing the shared flow state in workflow context.
|
||||
/// </summary>
|
||||
internal static class FlowStateShared
|
||||
{
|
||||
public const string Scope = "FlowStateScope";
|
||||
public const string Key = "singleton";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for reading and writing shared flow state.
|
||||
/// </summary>
|
||||
internal static class FlowStateHelpers
|
||||
{
|
||||
public static async Task<FlowState> ReadFlowStateAsync(IWorkflowContext context)
|
||||
{
|
||||
FlowState? state = await context.ReadStateAsync<FlowState>(FlowStateShared.Key, scopeName: FlowStateShared.Scope);
|
||||
return state ?? new FlowState();
|
||||
}
|
||||
|
||||
public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state)
|
||||
=> context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope);
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Data Transfer Objects
|
||||
// ====================================
|
||||
|
||||
/// <summary>
|
||||
/// Structured output schema for the Critic's decision.
|
||||
/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema.
|
||||
/// </summary>
|
||||
[Description("Critic's review decision including approval status and feedback")]
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")]
|
||||
internal sealed class CriticDecision
|
||||
{
|
||||
[JsonPropertyName("approved")]
|
||||
[Description("Whether the content is approved (true) or needs revision (false)")]
|
||||
public bool Approved { get; set; }
|
||||
|
||||
[JsonPropertyName("feedback")]
|
||||
[Description("Specific feedback for improvements if not approved, empty if approved")]
|
||||
public string Feedback { get; set; } = "";
|
||||
|
||||
// Non-JSON properties for workflow use
|
||||
[JsonIgnore]
|
||||
public string Content { get; set; } = "";
|
||||
|
||||
[JsonIgnore]
|
||||
public int Iteration { get; set; }
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Custom Executors
|
||||
// ====================================
|
||||
|
||||
/// <summary>
|
||||
/// Executor that creates or revises content based on user requests or critic feedback.
|
||||
/// This executor demonstrates multiple message handlers for different input types.
|
||||
/// </summary>
|
||||
internal sealed class WriterExecutor : Executor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
public WriterExecutor(IChatClient chatClient) : base("Writer")
|
||||
{
|
||||
this._agent = new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "Writer",
|
||||
instructions: """
|
||||
You are a skilled writer. Create clear, engaging content.
|
||||
If you receive feedback, carefully revise the content to address all concerns.
|
||||
Maintain the same topic and length requirements.
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder
|
||||
.AddHandler<string, ChatMessage>(this.HandleInitialRequestAsync)
|
||||
.AddHandler<CriticDecision, ChatMessage>(this.HandleRevisionRequestAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the initial writing request from the user.
|
||||
/// </summary>
|
||||
private async ValueTask<ChatMessage> HandleInitialRequestAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles revision requests from the critic with feedback.
|
||||
/// </summary>
|
||||
private async ValueTask<ChatMessage> HandleRevisionRequestAsync(
|
||||
CriticDecision decision,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string prompt = "Revise the following content based on this feedback:\n\n" +
|
||||
$"Feedback: {decision.Feedback}\n\n" +
|
||||
$"Original Content:\n{decision.Content}";
|
||||
|
||||
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation for generating content (initial or revised).
|
||||
/// </summary>
|
||||
private async Task<ChatMessage> HandleAsyncCoreAsync(
|
||||
ChatMessage message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
|
||||
|
||||
Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n");
|
||||
|
||||
StringBuilder sb = new();
|
||||
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
sb.Append(update.Text);
|
||||
Console.Write(update.Text);
|
||||
}
|
||||
}
|
||||
Console.WriteLine("\n");
|
||||
|
||||
string text = sb.ToString();
|
||||
state.History.Add(new ChatMessage(ChatRole.Assistant, text));
|
||||
await FlowStateHelpers.SaveFlowStateAsync(context, state);
|
||||
|
||||
return new ChatMessage(ChatRole.User, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that reviews content and decides whether to approve or request revisions.
|
||||
/// Uses structured output with streaming for reliable decision-making.
|
||||
/// </summary>
|
||||
internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
public CriticExecutor(IChatClient chatClient) : base("Critic")
|
||||
{
|
||||
this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Critic",
|
||||
Instructions = """
|
||||
You are a constructive critic. Review the content and provide specific feedback.
|
||||
Always try to provide actionable suggestions for improvement and strive to identify improvement points.
|
||||
Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points.
|
||||
|
||||
Provide your decision as structured output with:
|
||||
- approved: true if content is good, false if revisions needed
|
||||
- feedback: specific improvements needed (empty if approved)
|
||||
|
||||
Be concise but specific in your feedback.
|
||||
""",
|
||||
ChatOptions = new()
|
||||
{
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema<CriticDecision>()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override async ValueTask<CriticDecision> HandleAsync(
|
||||
ChatMessage message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
|
||||
|
||||
Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n");
|
||||
|
||||
// Use RunStreamingAsync to get streaming updates, then deserialize at the end
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken);
|
||||
|
||||
// Stream the output in real-time (for any rationale/explanation)
|
||||
await foreach (AgentRunResponseUpdate update in updates)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
Console.Write(update.Text);
|
||||
}
|
||||
}
|
||||
Console.WriteLine("\n");
|
||||
|
||||
// Convert the stream to a response and deserialize the structured output
|
||||
AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken);
|
||||
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
|
||||
|
||||
Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}");
|
||||
if (!string.IsNullOrEmpty(decision.Feedback))
|
||||
{
|
||||
Console.WriteLine($"Feedback: {decision.Feedback}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// Safety: approve if max iterations reached
|
||||
if (!decision.Approved && state.Iteration >= Program.MaxIterations)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving");
|
||||
Console.ResetColor();
|
||||
decision.Approved = true;
|
||||
decision.Feedback = "";
|
||||
}
|
||||
|
||||
// Increment iteration ONLY if rejecting (will loop back to Writer)
|
||||
if (!decision.Approved)
|
||||
{
|
||||
state.Iteration++;
|
||||
}
|
||||
|
||||
// Store the decision in history
|
||||
state.History.Add(new ChatMessage(ChatRole.Assistant,
|
||||
$"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}"));
|
||||
await FlowStateHelpers.SaveFlowStateAsync(context, state);
|
||||
|
||||
// Populate workflow-specific fields
|
||||
decision.Content = message.Text ?? "";
|
||||
decision.Iteration = state.Iteration;
|
||||
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that presents the final approved content to the user.
|
||||
/// </summary>
|
||||
internal sealed class SummaryExecutor : Executor<CriticDecision, ChatMessage>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
public SummaryExecutor(IChatClient chatClient) : base("Summary")
|
||||
{
|
||||
this._agent = new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "Summary",
|
||||
instructions: """
|
||||
You present the final approved content to the user.
|
||||
Simply output the polished content - no additional commentary needed.
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
public override async ValueTask<ChatMessage> HandleAsync(
|
||||
CriticDecision message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("=== Summary ===\n");
|
||||
|
||||
string prompt = $"Present this approved content:\n\n{message.Content}";
|
||||
|
||||
StringBuilder sb = new();
|
||||
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
sb.Append(update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
ChatMessage result = new(ChatRole.Assistant, sb.ToString());
|
||||
await context.YieldOutputAsync(result, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AIAgent"/> implementation that communicates with an AG-UI compliant server.
|
||||
/// </summary>
|
||||
public sealed class AGUIAgent : AIAgent
|
||||
{
|
||||
private readonly AGUIHttpService _client;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AGUIAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The agent ID.</param>
|
||||
/// <param name="description">Optional description of the agent.</param>
|
||||
/// <param name="httpClient">The HTTP client to use for communication with the AG-UI server.</param>
|
||||
/// <param name="endpoint">The URL for the AG-UI server.</param>
|
||||
public AGUIAgent(string id, string description, HttpClient httpClient, string endpoint)
|
||||
{
|
||||
this.Id = Throw.IfNullOrWhitespace(id);
|
||||
this.Description = description;
|
||||
this._client = new AGUIHttpService(
|
||||
httpClient ?? Throw.IfNull(httpClient),
|
||||
endpoint ?? Throw.IfNullOrEmpty(endpoint));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentThread GetNewThread() => new AGUIAgentThread();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
|
||||
new AGUIAgentThread(serializedThread, jsonSerializerOptions);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.RunStreamingAsync(messages, thread, null, cancellationToken)
|
||||
.ToAgentRunResponseAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
if ((thread ?? this.GetNewThread()) is not AGUIAgentThread typedThread)
|
||||
{
|
||||
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
|
||||
}
|
||||
|
||||
string runId = $"run_{Guid.NewGuid()}";
|
||||
|
||||
var llmMessages = typedThread.MessageStore.Concat(messages);
|
||||
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = typedThread.ThreadId,
|
||||
RunId = runId,
|
||||
Messages = llmMessages.AsAGUIMessages(),
|
||||
};
|
||||
|
||||
await foreach (var update in this._client.PostRunAsync(input, cancellationToken).AsAgentRunResponseUpdatesAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
|
||||
updates.Add(chatUpdate);
|
||||
yield return update;
|
||||
}
|
||||
|
||||
ChatResponse response = updates.ToChatResponse();
|
||||
await NotifyThreadOfNewMessagesAsync(typedThread, messages.Concat(response.Messages), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI;
|
||||
|
||||
internal sealed class AGUIAgentThread : InMemoryAgentThread
|
||||
{
|
||||
public AGUIAgentThread()
|
||||
: base()
|
||||
{
|
||||
this.ThreadId = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
public AGUIAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(UnwrapState(serializedThreadState), jsonSerializerOptions)
|
||||
{
|
||||
var threadId = serializedThreadState.TryGetProperty(nameof(AGUIAgentThreadState.ThreadId), out var stateElement)
|
||||
? stateElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrEmpty(threadId))
|
||||
{
|
||||
Throw.InvalidOperationException("Serialized thread is missing required ThreadId.");
|
||||
}
|
||||
this.ThreadId = threadId;
|
||||
}
|
||||
|
||||
private static JsonElement UnwrapState(JsonElement serializedThreadState)
|
||||
{
|
||||
var state = serializedThreadState.Deserialize(AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
|
||||
if (state == null)
|
||||
{
|
||||
Throw.InvalidOperationException("Serialized thread is missing required WrappedState.");
|
||||
}
|
||||
|
||||
return state.WrappedState;
|
||||
}
|
||||
|
||||
public string ThreadId { get; set; }
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var wrappedState = base.Serialize(jsonSerializerOptions);
|
||||
var state = new AGUIAgentThreadState
|
||||
{
|
||||
ThreadId = this.ThreadId,
|
||||
WrappedState = wrappedState,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
|
||||
}
|
||||
|
||||
internal sealed class AGUIAgentThreadState
|
||||
{
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
public JsonElement WrappedState { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI;
|
||||
|
||||
internal sealed class AGUIHttpService(HttpClient client, string endpoint)
|
||||
{
|
||||
public async IAsyncEnumerable<BaseEvent> PostRunAsync(
|
||||
RunAgentInput input,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput)
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
#if NET
|
||||
Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
Stream responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
var items = SseParser.Create(responseStream, ItemParser).EnumerateAsync(cancellationToken);
|
||||
await foreach (var sseItem in items.ConfigureAwait(false))
|
||||
{
|
||||
yield return sseItem.Data;
|
||||
}
|
||||
}
|
||||
|
||||
private static BaseEvent ItemParser(string type, ReadOnlySpan<byte> data)
|
||||
{
|
||||
return JsonSerializer.Deserialize(data, AGUIJsonSerializerContext.Default.BaseEvent) ??
|
||||
throw new InvalidOperationException("Failed to deserialize SSE item.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework AG-UI</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Net.Http.Json" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.AGUI.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.AGUI.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal static class AGUIChatMessageExtensions
|
||||
{
|
||||
private static readonly ChatRole s_developerChatRole = new("developer");
|
||||
|
||||
public static IEnumerable<ChatMessage> AsChatMessages(
|
||||
this IEnumerable<AGUIMessage> aguiMessages)
|
||||
{
|
||||
foreach (var message in aguiMessages)
|
||||
{
|
||||
yield return new ChatMessage(
|
||||
MapChatRole(message.Role),
|
||||
message.Content);
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<AGUIMessage> AsAGUIMessages(
|
||||
this IEnumerable<ChatMessage> chatMessages)
|
||||
{
|
||||
foreach (var message in chatMessages)
|
||||
{
|
||||
yield return new AGUIMessage
|
||||
{
|
||||
Id = message.MessageId,
|
||||
Role = message.Role.Value,
|
||||
Content = message.Text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static ChatRole MapChatRole(string role) =>
|
||||
string.Equals(role, AGUIRoles.System, StringComparison.OrdinalIgnoreCase) ? ChatRole.System :
|
||||
string.Equals(role, AGUIRoles.User, StringComparison.OrdinalIgnoreCase) ? ChatRole.User :
|
||||
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
|
||||
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
|
||||
throw new InvalidOperationException($"Unknown chat role: {role}");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal static class AGUIEventTypes
|
||||
{
|
||||
public const string RunStarted = "RUN_STARTED";
|
||||
|
||||
public const string RunFinished = "RUN_FINISHED";
|
||||
|
||||
public const string RunError = "RUN_ERROR";
|
||||
|
||||
public const string TextMessageStart = "TEXT_MESSAGE_START";
|
||||
|
||||
public const string TextMessageContent = "TEXT_MESSAGE_CONTENT";
|
||||
|
||||
public const string TextMessageEnd = "TEXT_MESSAGE_END";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
#else
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI;
|
||||
#endif
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
||||
[JsonSerializable(typeof(RunAgentInput))]
|
||||
[JsonSerializable(typeof(BaseEvent))]
|
||||
[JsonSerializable(typeof(RunStartedEvent))]
|
||||
[JsonSerializable(typeof(RunFinishedEvent))]
|
||||
[JsonSerializable(typeof(RunErrorEvent))]
|
||||
[JsonSerializable(typeof(TextMessageStartEvent))]
|
||||
[JsonSerializable(typeof(TextMessageContentEvent))]
|
||||
[JsonSerializable(typeof(TextMessageEndEvent))]
|
||||
#if !ASPNETCORE
|
||||
[JsonSerializable(typeof(AGUIAgentThread.AGUIAgentThreadState))]
|
||||
#endif
|
||||
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class AGUIMessage
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal static class AGUIRoles
|
||||
{
|
||||
public const string System = "system";
|
||||
|
||||
public const string User = "user";
|
||||
|
||||
public const string Assistant = "assistant";
|
||||
|
||||
public const string Developer = "developer";
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal static class AgentRunResponseUpdateAGUIExtensions
|
||||
{
|
||||
#if !ASPNETCORE
|
||||
public static async IAsyncEnumerable<AgentRunResponseUpdate> AsAgentRunResponseUpdatesAsync(
|
||||
this IAsyncEnumerable<BaseEvent> events,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? currentMessageId = null;
|
||||
ChatRole currentRole = default!;
|
||||
string? conversationId = null;
|
||||
string? responseId = null;
|
||||
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case RunStartedEvent runStarted:
|
||||
conversationId = runStarted.ThreadId;
|
||||
responseId = runStarted.RunId;
|
||||
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
|
||||
ChatRole.Assistant,
|
||||
[])
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ResponseId = responseId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
break;
|
||||
case RunFinishedEvent runFinished:
|
||||
if (!string.Equals(runFinished.ThreadId, conversationId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"The run finished event didn't match the run started event thread ID: {runFinished.ThreadId}, {conversationId}");
|
||||
}
|
||||
if (!string.Equals(runFinished.RunId, responseId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"The run finished event didn't match the run started event run ID: {runFinished.RunId}, {responseId}");
|
||||
}
|
||||
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
|
||||
ChatRole.Assistant, runFinished.Result?.GetRawText())
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ResponseId = responseId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
break;
|
||||
case RunErrorEvent runError:
|
||||
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
|
||||
ChatRole.Assistant,
|
||||
[(new ErrorContent(runError.Message) { ErrorCode = runError.Code })]));
|
||||
break;
|
||||
case TextMessageStartEvent textStart:
|
||||
if (currentRole != default || currentMessageId != null)
|
||||
{
|
||||
throw new InvalidOperationException("Received TextMessageStartEvent while another message is being processed.");
|
||||
}
|
||||
|
||||
currentRole = AGUIChatMessageExtensions.MapChatRole(textStart.Role);
|
||||
currentMessageId = textStart.MessageId;
|
||||
break;
|
||||
case TextMessageContentEvent textContent:
|
||||
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
|
||||
currentRole,
|
||||
textContent.Delta)
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ResponseId = responseId,
|
||||
MessageId = textContent.MessageId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
break;
|
||||
case TextMessageEndEvent textEnd:
|
||||
if (currentMessageId != textEnd.MessageId)
|
||||
{
|
||||
throw new InvalidOperationException("Received TextMessageEndEvent for a different message than the current one.");
|
||||
}
|
||||
currentRole = default!;
|
||||
currentMessageId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public static async IAsyncEnumerable<BaseEvent> AsAGUIEventStreamAsync(
|
||||
this IAsyncEnumerable<AgentRunResponseUpdate> updates,
|
||||
string threadId,
|
||||
string runId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new RunStartedEvent
|
||||
{
|
||||
ThreadId = threadId,
|
||||
RunId = runId
|
||||
};
|
||||
|
||||
string? currentMessageId = null;
|
||||
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var chatResponse = update.AsChatResponseUpdate();
|
||||
if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent && !string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// End the previous message if there was one
|
||||
if (currentMessageId is not null)
|
||||
{
|
||||
yield return new TextMessageEndEvent
|
||||
{
|
||||
MessageId = currentMessageId
|
||||
};
|
||||
}
|
||||
|
||||
// Start the new message
|
||||
yield return new TextMessageStartEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
Role = chatResponse.Role!.Value.Value
|
||||
};
|
||||
|
||||
currentMessageId = chatResponse.MessageId;
|
||||
}
|
||||
|
||||
// Emit text content if present
|
||||
if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent textContent)
|
||||
{
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
Delta = textContent.Text ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// End the last message if there was one
|
||||
if (currentMessageId is not null)
|
||||
{
|
||||
yield return new TextMessageEndEvent
|
||||
{
|
||||
MessageId = currentMessageId
|
||||
};
|
||||
}
|
||||
|
||||
yield return new RunFinishedEvent
|
||||
{
|
||||
ThreadId = threadId,
|
||||
RunId = runId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
[JsonConverter(typeof(BaseEventJsonConverter))]
|
||||
internal abstract class BaseEvent
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON converter for polymorphic deserialization of BaseEvent and its derived types.
|
||||
/// Uses the "type" property as a discriminator to determine the concrete type to deserialize.
|
||||
/// </summary>
|
||||
internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
|
||||
{
|
||||
private const string TypeDiscriminatorPropertyName = "type";
|
||||
|
||||
public override bool CanConvert(Type typeToConvert) =>
|
||||
typeof(BaseEvent).IsAssignableFrom(typeToConvert);
|
||||
|
||||
public override BaseEvent Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
// Parse the JSON into a JsonDocument to inspect properties
|
||||
using JsonDocument document = JsonDocument.ParseValue(ref reader);
|
||||
JsonElement jsonElement = document.RootElement.Clone();
|
||||
|
||||
// Try to get the discriminator property
|
||||
if (!jsonElement.TryGetProperty(TypeDiscriminatorPropertyName, out JsonElement discriminatorElement))
|
||||
{
|
||||
throw new JsonException($"Missing required property '{TypeDiscriminatorPropertyName}' for BaseEvent deserialization");
|
||||
}
|
||||
|
||||
string? discriminator = discriminatorElement.GetString();
|
||||
|
||||
#if ASPNETCORE
|
||||
AGUIJsonSerializerContext context = (AGUIJsonSerializerContext)options.TypeInfoResolver!;
|
||||
#else
|
||||
AGUIJsonSerializerContext context = AGUIJsonSerializerContext.Default;
|
||||
#endif
|
||||
|
||||
// Map discriminator to concrete type and deserialize using the serializer context
|
||||
BaseEvent? result = discriminator switch
|
||||
{
|
||||
AGUIEventTypes.RunStarted => jsonElement.Deserialize(context.RunStartedEvent),
|
||||
AGUIEventTypes.RunFinished => jsonElement.Deserialize(context.RunFinishedEvent),
|
||||
AGUIEventTypes.RunError => jsonElement.Deserialize(context.RunErrorEvent),
|
||||
AGUIEventTypes.TextMessageStart => jsonElement.Deserialize(context.TextMessageStartEvent),
|
||||
AGUIEventTypes.TextMessageContent => jsonElement.Deserialize(context.TextMessageContentEvent),
|
||||
AGUIEventTypes.TextMessageEnd => jsonElement.Deserialize(context.TextMessageEndEvent),
|
||||
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
|
||||
};
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
throw new JsonException($"Failed to deserialize BaseEvent with type discriminator: '{discriminator}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
BaseEvent value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
#if ASPNETCORE
|
||||
AGUIJsonSerializerContext context = (AGUIJsonSerializerContext)options.TypeInfoResolver!;
|
||||
#else
|
||||
AGUIJsonSerializerContext context = AGUIJsonSerializerContext.Default;
|
||||
#endif
|
||||
|
||||
// Serialize the concrete type directly using the serializer context
|
||||
switch (value)
|
||||
{
|
||||
case RunStartedEvent runStarted:
|
||||
JsonSerializer.Serialize(writer, runStarted, context.RunStartedEvent);
|
||||
break;
|
||||
case RunFinishedEvent runFinished:
|
||||
JsonSerializer.Serialize(writer, runFinished, context.RunFinishedEvent);
|
||||
break;
|
||||
case RunErrorEvent runError:
|
||||
JsonSerializer.Serialize(writer, runError, context.RunErrorEvent);
|
||||
break;
|
||||
case TextMessageStartEvent textStart:
|
||||
JsonSerializer.Serialize(writer, textStart, context.TextMessageStartEvent);
|
||||
break;
|
||||
case TextMessageContentEvent textContent:
|
||||
JsonSerializer.Serialize(writer, textContent, context.TextMessageContentEvent);
|
||||
break;
|
||||
case TextMessageEndEvent textEnd:
|
||||
JsonSerializer.Serialize(writer, textEnd, context.TextMessageEndEvent);
|
||||
break;
|
||||
default:
|
||||
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class RunAgentInput
|
||||
{
|
||||
[JsonPropertyName("threadId")]
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public JsonElement State { get; set; }
|
||||
|
||||
[JsonPropertyName("messages")]
|
||||
public IEnumerable<AGUIMessage> Messages { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("context")]
|
||||
public Dictionary<string, string> Context { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
[JsonPropertyName("forwardedProperties")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public JsonElement ForwardedProperties { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class RunErrorEvent : BaseEvent
|
||||
{
|
||||
public RunErrorEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.RunError;
|
||||
}
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class RunFinishedEvent : BaseEvent
|
||||
{
|
||||
public RunFinishedEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.RunFinished;
|
||||
}
|
||||
|
||||
[JsonPropertyName("threadId")]
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public JsonElement? Result { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class RunStartedEvent : BaseEvent
|
||||
{
|
||||
public RunStartedEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.RunStarted;
|
||||
}
|
||||
|
||||
[JsonPropertyName("threadId")]
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class TextMessageContentEvent : BaseEvent
|
||||
{
|
||||
public TextMessageContentEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.TextMessageContent;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public string Delta { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class TextMessageEndEvent : BaseEvent
|
||||
{
|
||||
public TextMessageEndEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.TextMessageEnd;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class TextMessageStartEvent : BaseEvent
|
||||
{
|
||||
public TextMessageStartEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.TextMessageStart;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for configuring the Microsoft Agents AI DevUI in ASP.NET applications.
|
||||
/// </summary>
|
||||
public static class DevUIExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the necessary services for the DevUI to the application builder.
|
||||
/// </summary>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
builder.Services.AddOpenAIConversations();
|
||||
builder.Services.AddOpenAIResponses();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI from the '/devui' path.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
public static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("");
|
||||
group.MapDevUI(pattern: "/devui");
|
||||
group.MapEntities();
|
||||
group.MapOpenAIConversations();
|
||||
group.MapOpenAIResponses();
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <param name="pattern">
|
||||
/// The route pattern for the endpoint (e.g., "/devui", "/agent-ui").
|
||||
/// Defaults to "/devui" if not specified. This is the path where DevUI will be accessible.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="pattern"/> is null or whitespace.</exception>
|
||||
internal static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("Route")] string pattern = "/devui")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
|
||||
|
||||
// Ensure the pattern doesn't end with a slash for consistency
|
||||
var cleanPattern = pattern.TrimEnd('/');
|
||||
|
||||
// Create the DevUI handler
|
||||
var logger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIMiddleware>>();
|
||||
var devUIHandler = new DevUIMiddleware(logger, cleanPattern);
|
||||
|
||||
return endpoints.MapGet($"{cleanPattern}/{{*path}}", devUIHandler.HandleRequestAsync)
|
||||
.WithName($"DevUI at {cleanPattern}")
|
||||
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Handler that serves embedded DevUI resource files from the 'resources' directory.
|
||||
/// </summary>
|
||||
internal sealed class DevUIMiddleware
|
||||
{
|
||||
private const string GZipEncodingValue = "gzip";
|
||||
private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue);
|
||||
private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly;
|
||||
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
|
||||
private static readonly StringValues s_cacheControl = new(new CacheControlHeaderValue()
|
||||
{
|
||||
NoCache = true,
|
||||
NoStore = true,
|
||||
}.ToString());
|
||||
|
||||
private readonly ILogger<DevUIMiddleware> _logger;
|
||||
private readonly FrozenDictionary<string, ResourceEntry> _resourceCache;
|
||||
private readonly string _basePath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevUIMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="basePath">The base path where DevUI is mounted.</param>
|
||||
public DevUIMiddleware(ILogger<DevUIMiddleware> logger, string basePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentException.ThrowIfNullOrEmpty(basePath);
|
||||
this._logger = logger;
|
||||
this._basePath = basePath.TrimEnd('/');
|
||||
|
||||
// Build resource cache
|
||||
var resourceNamePrefix = $"{s_assembly.GetName().Name}.resources.";
|
||||
this._resourceCache = s_assembly
|
||||
.GetManifestResourceNames()
|
||||
.Where(p => p.StartsWith(resourceNamePrefix, StringComparison.Ordinal))
|
||||
.ToFrozenDictionary(
|
||||
p => p[resourceNamePrefix.Length..].Replace('.', '/'),
|
||||
CreateResourceEntry,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an HTTP request for DevUI resources.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
public async Task HandleRequestAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
|
||||
if (path == null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// If requesting the base path without a trailing slash, redirect to include it
|
||||
// This ensures relative URLs in the HTML work correctly
|
||||
if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/'))
|
||||
{
|
||||
var redirectUrl = $"{path}/";
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
redirectUrl += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirectUrl;
|
||||
this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", path, redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the base path to get the resource path
|
||||
var resourcePath = path.StartsWith(this._basePath, StringComparison.OrdinalIgnoreCase)
|
||||
? path.Substring(this._basePath.Length).TrimStart('/')
|
||||
: path.TrimStart('/');
|
||||
|
||||
// If requesting the base path, serve index.html
|
||||
if (string.IsNullOrEmpty(resourcePath))
|
||||
{
|
||||
resourcePath = "index.html";
|
||||
}
|
||||
|
||||
// Try to serve the embedded resource
|
||||
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If resource not found, try serving index.html for client-side routing
|
||||
if (!resourcePath.Contains('.', StringComparison.Ordinal) || resourcePath.EndsWith('/'))
|
||||
{
|
||||
if (await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resource not found
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!this._resourceCache.TryGetValue(resourcePath.Replace('.', '/'), out var cacheEntry))
|
||||
{
|
||||
this._logger.LogDebug("Embedded resource not found: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// Check if client has cached version
|
||||
if (context.Request.Headers.IfNoneMatch == cacheEntry.ETag)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status304NotModified;
|
||||
this._logger.LogDebug("Resource not modified (304): {ResourcePath}", resourcePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseHeaders = response.Headers;
|
||||
|
||||
byte[] content;
|
||||
bool serveCompressed;
|
||||
if (cacheEntry.CompressedContent is not null && IsGZipAccepted(context.Request))
|
||||
{
|
||||
serveCompressed = true;
|
||||
responseHeaders.ContentEncoding = s_gzipEncodingHeader;
|
||||
responseHeaders.ContentLength = cacheEntry.CompressedContent.Length;
|
||||
content = cacheEntry.CompressedContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
serveCompressed = false;
|
||||
responseHeaders.ContentLength = cacheEntry.DecompressedContent!.Length;
|
||||
content = cacheEntry.DecompressedContent;
|
||||
}
|
||||
|
||||
responseHeaders.CacheControl = s_cacheControl;
|
||||
responseHeaders.ContentType = cacheEntry.ContentType;
|
||||
responseHeaders.ETag = cacheEntry.ETag;
|
||||
|
||||
await response.Body.WriteAsync(content, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogDebug("Served embedded resource: {ResourcePath} (compressed: {Compressed})", resourcePath, serveCompressed);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error serving embedded resource: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsGZipAccepted(HttpRequest httpRequest)
|
||||
{
|
||||
if (httpRequest.GetTypedHeaders().AcceptEncoding is not { Count: > 0 } acceptEncoding)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < acceptEncoding.Count; i++)
|
||||
{
|
||||
var encoding = acceptEncoding[i];
|
||||
|
||||
if (encoding.Quality is not 0 &&
|
||||
string.Equals(encoding.Value.Value, GZipEncodingValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ResourceEntry CreateResourceEntry(string resourceName)
|
||||
{
|
||||
using var resourceStream = s_assembly.GetManifestResourceStream(resourceName)!;
|
||||
using var decompressedContent = new MemoryStream();
|
||||
|
||||
// Read and cache the original resource content
|
||||
resourceStream.CopyTo(decompressedContent);
|
||||
var decompressedArray = decompressedContent.ToArray();
|
||||
|
||||
// Compress the content
|
||||
using var compressedContent = new MemoryStream();
|
||||
using (var gzip = new GZipStream(compressedContent, CompressionMode.Compress, leaveOpen: true))
|
||||
{
|
||||
// This is a synchronous write to a memory stream.
|
||||
// There is no benefit to asynchrony here.
|
||||
gzip.Write(decompressedArray);
|
||||
}
|
||||
|
||||
// Only use compression if it actually reduces size
|
||||
byte[]? compressedArray = compressedContent.Length < decompressedArray.Length
|
||||
? compressedContent.ToArray()
|
||||
: null;
|
||||
|
||||
var hash = SHA256.HashData(compressedArray ?? decompressedArray);
|
||||
var eTag = $"\"{Convert.ToBase64String(hash)}\"";
|
||||
|
||||
// Determine content type from resource name
|
||||
var contentType = s_contentTypeProvider.TryGetContentType(resourceName, out var ct)
|
||||
? ct
|
||||
: "application/octet-stream";
|
||||
|
||||
return new ResourceEntry(resourceName, decompressedArray, compressedArray, eTag, contentType);
|
||||
}
|
||||
|
||||
private sealed class ResourceEntry(string resourceName, byte[] decompressedContent, byte[]? compressedContent, string eTag, string contentType)
|
||||
{
|
||||
public byte[]? CompressedContent { get; } = compressedContent;
|
||||
|
||||
public string ContentType { get; } = contentType;
|
||||
|
||||
public byte[] DecompressedContent { get; } = decompressedContent;
|
||||
|
||||
public string ETag { get; } = eTag;
|
||||
|
||||
public string ResourceName { get; } = resourceName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for entity-related types.
|
||||
/// Enables AOT-compatible JSON serialization using source generators.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EntityInfo))]
|
||||
[JsonSerializable(typeof(DiscoveryResponse))]
|
||||
[JsonSerializable(typeof(EnvVarRequirement))]
|
||||
[JsonSerializable(typeof(List<EntityInfo>))]
|
||||
[JsonSerializable(typeof(List<JsonElement>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an environment variable required by an entity.
|
||||
/// </summary>
|
||||
internal sealed record EnvVarRequirement(
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("required")]
|
||||
bool Required = true,
|
||||
|
||||
[property: JsonPropertyName("example")]
|
||||
string? Example = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Information about an entity (agent or workflow).
|
||||
/// </summary>
|
||||
internal sealed record EntityInfo(
|
||||
[property: JsonPropertyName("id")]
|
||||
string Id,
|
||||
|
||||
[property: JsonPropertyName("type")]
|
||||
string Type,
|
||||
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("framework")]
|
||||
string Framework = "dotnet",
|
||||
|
||||
[property: JsonPropertyName("tools")]
|
||||
List<string>? Tools = null,
|
||||
|
||||
[property: JsonPropertyName("metadata")]
|
||||
Dictionary<string, JsonElement>? Metadata = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; } = "di";
|
||||
|
||||
[JsonPropertyName("original_url")]
|
||||
public string? OriginalUrl { get; init; }
|
||||
|
||||
// Workflow-specific fields
|
||||
[JsonPropertyName("required_env_vars")]
|
||||
public List<EnvVarRequirement>? RequiredEnvVars { get; init; }
|
||||
|
||||
[JsonPropertyName("executors")]
|
||||
public List<string>? Executors { get; init; }
|
||||
|
||||
[JsonPropertyName("workflow_dump")]
|
||||
public JsonElement? WorkflowDump { get; init; }
|
||||
|
||||
[JsonPropertyName("input_schema")]
|
||||
public JsonElement? InputSchema { get; init; }
|
||||
|
||||
[JsonPropertyName("input_type_name")]
|
||||
public string? InputTypeName { get; init; }
|
||||
|
||||
[JsonPropertyName("start_executor_id")]
|
||||
public string? StartExecutorId { get; init; }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of discovered entities.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveryResponse(
|
||||
[property: JsonPropertyName("entities")]
|
||||
List<EntityInfo> Entities
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for serializing workflows to DevUI-compatible format
|
||||
/// </summary>
|
||||
internal static class WorkflowSerializationExtensions
|
||||
{
|
||||
// The frontend max iterations default value expected by the DevUI frontend
|
||||
private const int MaxIterationsDefault = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a workflow to a dictionary representation compatible with DevUI frontend.
|
||||
/// This matches the Python workflow.to_dict() format expected by the UI.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object> ToDevUIDict(this Workflow workflow)
|
||||
{
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = workflow.Name ?? Guid.NewGuid().ToString(),
|
||||
["start_executor_id"] = workflow.StartExecutorId,
|
||||
["max_iterations"] = MaxIterationsDefault
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (!string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
result["name"] = workflow.Name;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(workflow.Description))
|
||||
{
|
||||
result["description"] = workflow.Description;
|
||||
}
|
||||
|
||||
// Convert executors to Python-compatible format
|
||||
result["executors"] = ConvertExecutorsToDict(workflow);
|
||||
|
||||
// Convert edges to edge_groups format
|
||||
result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow executors to a dictionary format compatible with Python
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> ConvertExecutorsToDict(Workflow workflow)
|
||||
{
|
||||
var executors = new Dictionary<string, object>();
|
||||
|
||||
// Extract executor IDs from edges and start executor
|
||||
// (Registrations is internal, so we infer executors from the graph structure)
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor entries (we can't access internal Registrations for type info)
|
||||
foreach (var executorId in executorIds)
|
||||
{
|
||||
executors[executorId] = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = executorId,
|
||||
["type"] = "Executor"
|
||||
};
|
||||
}
|
||||
|
||||
return executors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow edges to edge_groups format expected by the UI
|
||||
/// </summary>
|
||||
private static List<object> ConvertEdgesToEdgeGroups(Workflow workflow)
|
||||
{
|
||||
var edgeGroups = new List<object>();
|
||||
var edgeGroupId = 0;
|
||||
|
||||
// Get edges using the public ReflectEdges method
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
foreach (var edgeInfo in edgeSet)
|
||||
{
|
||||
if (edgeInfo is DirectEdgeInfo directEdge)
|
||||
{
|
||||
// Single edge group for direct edges
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in directEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in directEdge.Connection.SinkIds)
|
||||
{
|
||||
var edge = new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
};
|
||||
|
||||
// Add condition name if this is a conditional edge
|
||||
if (directEdge.HasCondition)
|
||||
{
|
||||
edge["condition_name"] = "predicate";
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "SingleEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
else if (edgeInfo is FanOutEdgeInfo fanOutEdge)
|
||||
{
|
||||
// FanOut edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanOutEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanOutEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var fanOutGroup = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanOutEdgeGroup",
|
||||
["edges"] = edges
|
||||
};
|
||||
|
||||
if (fanOutEdge.HasAssigner)
|
||||
{
|
||||
fanOutGroup["selection_func_name"] = "selector";
|
||||
}
|
||||
|
||||
edgeGroups.Add(fanOutGroup);
|
||||
}
|
||||
else if (edgeInfo is FanInEdgeInfo fanInEdge)
|
||||
{
|
||||
// FanIn edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanInEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanInEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanInEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgeGroups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Agents.AI.DevUI.Entities;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping entity discovery and management endpoints to an <see cref="IEndpointRouteBuilder"/>.
|
||||
/// </summary>
|
||||
internal static class EntitiesApiExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps HTTP API endpoints for entity discovery and management.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the routes to.</param>
|
||||
/// <returns>The <see cref="IEndpointRouteBuilder"/> for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This extension method registers the following endpoints:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>GET /v1/entities - List all registered entities (agents and workflows)</description></item>
|
||||
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
|
||||
/// </list>
|
||||
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
|
||||
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/v1/entities")
|
||||
.WithTags("Entities");
|
||||
|
||||
// List all entities
|
||||
group.MapGet("", ListEntitiesAsync)
|
||||
.WithName("ListEntities")
|
||||
.WithSummary("List all registered entities (agents and workflows)")
|
||||
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
// Get detailed entity information
|
||||
group.MapGet("{entityId}/info", GetEntityInfoAsync)
|
||||
.WithName("GetEntityInfo")
|
||||
.WithSummary("Get detailed information about a specific entity")
|
||||
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEntitiesAsync(
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = new List<EntityInfo>();
|
||||
|
||||
// Discover agents from the agent catalog
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discover workflows from the workflow catalog
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: workflow.Name ?? workflow.StartExecutorId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "string",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error listing entities");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntityInfoAsync(
|
||||
string entityId,
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to find the entity among discovered agents
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the entity among discovered workflows
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var workflowId = workflow.Name ?? workflow.StartExecutorId;
|
||||
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: workflowId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "Input",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error getting entity info");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Frontend paths - pointing to the Python package's frontend -->
|
||||
<FrontendRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\frontend'))</FrontendRoot>
|
||||
<FrontendBuildOutput>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\agent_framework_devui\ui'))</FrontendBuildOutput>
|
||||
<FrontendPackageJson>$(FrontendRoot)\package.json</FrontendPackageJson>
|
||||
<FrontendNodeModules>$(FrontendRoot)\node_modules</FrontendNodeModules>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Ensure npm packages are installed before building -->
|
||||
<Target Name="EnsureNodeModules" BeforeTargets="BeforeBuild" Condition="!Exists('$(FrontendNodeModules)')">
|
||||
<Exec Command="npm install" WorkingDirectory="$(FrontendRoot)" />
|
||||
</Target>
|
||||
|
||||
<!-- Collect frontend source files for incremental build tracking -->
|
||||
<ItemGroup>
|
||||
<FrontendSourceFiles Include="$(FrontendRoot)\src\**\*" />
|
||||
<FrontendSourceFiles Include="$(FrontendPackageJson);$(FrontendRoot)\vite.config.ts;$(FrontendRoot)\tsconfig.json;$(FrontendRoot)\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Define the required frontend assets -->
|
||||
<ItemGroup>
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\index.html" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.js" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.css" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\agentframework.svg" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Use a marker file for incremental build tracking -->
|
||||
<PropertyGroup>
|
||||
<FrontendBuildMarker>$(BaseIntermediateOutputPath)\frontend.build.marker</FrontendBuildMarker>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Build the frontend -->
|
||||
<Target Name="BuildFrontend" BeforeTargets="AssignTargetPaths" DependsOnTargets="EnsureNodeModules" Inputs="@(FrontendSourceFiles)" Outputs="$(FrontendBuildMarker)">
|
||||
<!-- Set VITE_API_BASE_URL to empty string for relative URLs -->
|
||||
<Exec Command="npm run build" WorkingDirectory="$(FrontendRoot)" EnvironmentVariables="VITE_API_BASE_URL=" />
|
||||
<!-- Create marker file to track successful build -->
|
||||
<Touch Files="$(FrontendBuildMarker)" AlwaysCreate="true" />
|
||||
</Target>
|
||||
|
||||
<!-- Statically include frontend assets as embedded resources for VS to show them -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(FrontendBuildOutput)\**\*" Condition="Exists('$(FrontendBuildOutput)')">
|
||||
<Link>resources\$([MSBuild]::MakeRelative('$(FrontendBuildOutput)', '%(Identity)'))</Link>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Verify required frontend assets are present -->
|
||||
<Target Name="ValidateFrontendAssets" BeforeTargets="CoreCompile" DependsOnTargets="BuildFrontend">
|
||||
<ItemGroup>
|
||||
<MissingAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingAsset)' != ''" Text="Required frontend assets are missing: @(MissingAsset, ', '). Frontend build may have failed." />
|
||||
</Target>
|
||||
|
||||
<!-- Verify assets are present before packing -->
|
||||
<Target Name="ValidateFrontendAssetsBeforePack" BeforeTargets="GenerateNuspec">
|
||||
<ItemGroup>
|
||||
<MissingPackageAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingPackageAsset)' != ''" Text="Cannot create NuGet package: Required frontend assets are missing: @(MissingPackageAsset, ', ')" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
|
||||
<OutputType>Library</OutputType>
|
||||
<Title>Microsoft Agent Framework Developer UI</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
<!-- Suppress warnings for internal DevUI implementation -->
|
||||
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Import nuget packaging properties -->
|
||||
<Import Project="..\..\nuget\nuget-package.props" />
|
||||
|
||||
<!-- Import frontend web assets build targets -->
|
||||
<Import Project="Microsoft.Agents.AI.DevUI.Frontend.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Microsoft.Agents.AI.DevUI": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:57966;http://localhost:57967"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Microsoft.Agents.AI.DevUI
|
||||
|
||||
This package provides a web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DevUI
|
||||
dotnet add package Microsoft.Agents.AI.Hosting
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Add DevUI services and map the endpoint in your ASP.NET Core application:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register your agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Map DevUI endpoint to /devui
|
||||
app.MapDevUI();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="./assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping AG-UI agents to ASP.NET Core endpoints.
|
||||
/// </summary>
|
||||
public static class AGUIEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an AG-UI agent endpoint.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="pattern">The URL pattern for the endpoint.</param>
|
||||
/// <param name="aiAgent">The agent instance.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("route")] string pattern,
|
||||
AIAgent aiAgent)
|
||||
{
|
||||
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var messages = input.Messages.AsChatMessages();
|
||||
var agent = aiAgent;
|
||||
|
||||
var events = agent.RunStreamingAsync(
|
||||
messages,
|
||||
cancellationToken: cancellationToken)
|
||||
.AsAGUIEventStreamAsync(
|
||||
input.ThreadId,
|
||||
input.RunId,
|
||||
cancellationToken);
|
||||
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
|
||||
return new AGUIServerSentEventsResult(events, logger);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
|
||||
internal sealed partial class AGUIServerSentEventsResult : IResult, IDisposable
|
||||
{
|
||||
private readonly IAsyncEnumerable<BaseEvent> _events;
|
||||
private readonly ILogger<AGUIServerSentEventsResult> _logger;
|
||||
private Utf8JsonWriter? _jsonWriter;
|
||||
|
||||
public int? StatusCode => StatusCodes.Status200OK;
|
||||
|
||||
internal AGUIServerSentEventsResult(IAsyncEnumerable<BaseEvent> events, ILogger<AGUIServerSentEventsResult> logger)
|
||||
{
|
||||
this._events = events;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
if (httpContext == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(httpContext));
|
||||
}
|
||||
|
||||
httpContext.Response.ContentType = "text/event-stream";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache,no-store";
|
||||
httpContext.Response.Headers.Pragma = "no-cache";
|
||||
|
||||
var body = httpContext.Response.Body;
|
||||
var cancellationToken = httpContext.RequestAborted;
|
||||
|
||||
try
|
||||
{
|
||||
await SseFormatter.WriteAsync(
|
||||
WrapEventsAsSseItemsAsync(this._events, cancellationToken),
|
||||
body,
|
||||
this.SerializeEvent,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogStreamingError(this._logger, ex);
|
||||
// If an error occurs during streaming, try to send an error event before closing
|
||||
try
|
||||
{
|
||||
var errorEvent = new RunErrorEvent
|
||||
{
|
||||
Code = "StreamingError",
|
||||
Message = ex.Message
|
||||
};
|
||||
await SseFormatter.WriteAsync(
|
||||
WrapEventsAsSseItemsAsync([errorEvent]),
|
||||
body,
|
||||
this.SerializeEvent,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception sendErrorEx)
|
||||
{
|
||||
// If we can't send the error event, just let the connection close
|
||||
LogSendErrorEventFailed(this._logger, sendErrorEx);
|
||||
}
|
||||
}
|
||||
|
||||
await body.FlushAsync(httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<SseItem<BaseEvent>> WrapEventsAsSseItemsAsync(
|
||||
IAsyncEnumerable<BaseEvent> events,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (BaseEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return new SseItem<BaseEvent>(evt);
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<SseItem<BaseEvent>> WrapEventsAsSseItemsAsync(
|
||||
IEnumerable<BaseEvent> events)
|
||||
{
|
||||
foreach (BaseEvent evt in events)
|
||||
{
|
||||
yield return new SseItem<BaseEvent>(evt);
|
||||
}
|
||||
}
|
||||
|
||||
private void SerializeEvent(SseItem<BaseEvent> item, IBufferWriter<byte> writer)
|
||||
{
|
||||
if (this._jsonWriter == null)
|
||||
{
|
||||
this._jsonWriter = new Utf8JsonWriter(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._jsonWriter.Reset(writer);
|
||||
}
|
||||
JsonSerializer.Serialize(this._jsonWriter, item.Data, AGUIJsonSerializerContext.Default.BaseEvent);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._jsonWriter?.Dispose();
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "An error occurred while streaming AG-UI events",
|
||||
SkipEnabledCheck = true)]
|
||||
private static partial void LogStreamingError(ILogger logger, Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Failed to send error event to client after streaming failure",
|
||||
SkipEnabledCheck = true)]
|
||||
private static partial void LogSendErrorEventFailed(ILogger logger, Exception exception);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AGUI.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<DefineConstants>$(DefineConstants);ASPNETCORE</DefineConstants>
|
||||
<IsPackable>false</IsPackable>
|
||||
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting AG-UI ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting AG-UI agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Microsoft.Agents.AI.AGUI\Shared\**\*.cs" LinkBase="Shared" />
|
||||
<Compile Remove="ServerSentEventsResult.cs" Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '10.0'))" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+102
-55
@@ -1,70 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Buffers;
|
||||
using System.ClientModel.Primitives;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using OpenAI.Chat;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
|
||||
|
||||
internal sealed class AIAgentChatCompletionsProcessor
|
||||
internal static class AIAgentChatCompletionsProcessor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
public AIAgentChatCompletionsProcessor(AIAgent agent)
|
||||
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
|
||||
{
|
||||
this._agent = agent;
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
public async Task<IResult> CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
AgentThread? agentThread = null; // not supported to resolve from conversationId
|
||||
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
|
||||
var chatClientAgentRunOptions = request.BuildOptions();
|
||||
|
||||
var inputItems = chatCompletionOptions.GetMessages();
|
||||
var chatMessages = inputItems.AsChatMessages();
|
||||
|
||||
if (chatCompletionOptions.GetStream())
|
||||
if (request.Stream == true)
|
||||
{
|
||||
return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages);
|
||||
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
|
||||
}
|
||||
|
||||
var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return new OpenAIChatCompletionResult(agentResponse);
|
||||
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response.ToChatCompletion(request));
|
||||
}
|
||||
|
||||
private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult
|
||||
{
|
||||
public async Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
// note: OpenAI SDK types provide their own serialization implementation
|
||||
// so we cant simply return IResult wrap for the typed-object.
|
||||
// instead writing to the response body can be done.
|
||||
|
||||
var cancellationToken = httpContext.RequestAborted;
|
||||
var response = httpContext.Response;
|
||||
|
||||
var chatResponse = agentRunResponse.AsChatResponse();
|
||||
var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion();
|
||||
var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel<ChatCompletion>;
|
||||
Debug.Assert(openAIChatCompletionJsonModel is not null);
|
||||
|
||||
var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false });
|
||||
openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json);
|
||||
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable<ChatMessage> chatMessages) : IResult
|
||||
private sealed class StreamingResponse(
|
||||
AIAgent agent,
|
||||
CreateChatCompletion request,
|
||||
IEnumerable<ChatMessage> chatMessages,
|
||||
ChatClientAgentRunOptions? options) : IResult
|
||||
{
|
||||
public Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
@@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor
|
||||
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
|
||||
|
||||
return SseFormatter.WriteAsync(
|
||||
source: this.GetStreamingResponsesAsync(cancellationToken),
|
||||
source: this.GetStreamingChunksAsync(cancellationToken),
|
||||
destination: response.Body,
|
||||
itemFormatter: (sseItem, bufferWriter) =>
|
||||
{
|
||||
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)sseItem.Data;
|
||||
var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json);
|
||||
bufferWriter.Write(json);
|
||||
using var writer = new Utf8JsonWriter(bufferWriter);
|
||||
JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk);
|
||||
writer.Flush();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<SseItem<StreamingChatCompletionUpdate>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
private async IAsyncEnumerable<SseItem<ChatCompletionChunk>> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentThread? agentThread = null;
|
||||
// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
|
||||
DateTimeOffset? createdAt = null;
|
||||
var chunkId = IdGenerator.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13);
|
||||
|
||||
var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken);
|
||||
var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync();
|
||||
await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false))
|
||||
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return new SseItem<StreamingChatCompletionUpdate>(streamingChatCompletionUpdate);
|
||||
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
|
||||
? chatResponseUpdate.FinishReason.ToString()
|
||||
: "stop";
|
||||
|
||||
var choiceChunks = new List<ChatCompletionChoiceChunk>();
|
||||
CompletionUsage? usageDetails = null;
|
||||
|
||||
createdAt ??= agentRunResponseUpdate.CreatedAt;
|
||||
|
||||
foreach (var content in agentRunResponseUpdate.Contents)
|
||||
{
|
||||
// usage content is handled separately
|
||||
if (content is UsageContent usageContent && usageContent.Details != null)
|
||||
{
|
||||
usageDetails = usageContent.Details.ToCompletionUsage();
|
||||
continue;
|
||||
}
|
||||
|
||||
ChatCompletionDelta? delta = content switch
|
||||
{
|
||||
TextContent textContent => new() { Content = textContent.Text },
|
||||
|
||||
// image
|
||||
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() },
|
||||
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() },
|
||||
|
||||
// audio
|
||||
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() },
|
||||
|
||||
// file
|
||||
DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() },
|
||||
HostedFileContent fileContent => new() { Content = fileContent.FileId },
|
||||
|
||||
// function call
|
||||
FunctionCallContent functionCallContent => new()
|
||||
{
|
||||
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
|
||||
},
|
||||
|
||||
// function result. ChatCompletions dont provide the results of function result per API reference
|
||||
FunctionResultContent functionResultContent => null,
|
||||
|
||||
// ignore
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (delta is null)
|
||||
{
|
||||
// unsupported but expected content type.
|
||||
continue;
|
||||
}
|
||||
|
||||
delta.Role = agentRunResponseUpdate.Role?.Value ?? "user";
|
||||
|
||||
var choiceChunk = new ChatCompletionChoiceChunk
|
||||
{
|
||||
Index = 0,
|
||||
Delta = delta,
|
||||
FinishReason = finishReason
|
||||
};
|
||||
|
||||
choiceChunks.Add(choiceChunk);
|
||||
}
|
||||
|
||||
var chunk = new ChatCompletionChunk
|
||||
{
|
||||
Id = chunkId,
|
||||
Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
|
||||
Model = request.Model,
|
||||
Choices = choiceChunks,
|
||||
Usage = usageDetails
|
||||
};
|
||||
|
||||
yield return new(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for converting agent responses to ChatCompletion models.
|
||||
/// </summary>
|
||||
internal static class AgentRunResponseExtensions
|
||||
{
|
||||
public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request)
|
||||
{
|
||||
IList<ChatCompletionChoice> choices = agentRunResponse.ToChoices();
|
||||
|
||||
return new ChatCompletion
|
||||
{
|
||||
Id = IdGenerator.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13),
|
||||
Choices = choices,
|
||||
Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
|
||||
Model = request.Model,
|
||||
Usage = agentRunResponse.Usage.ToCompletionUsage(),
|
||||
ServiceTier = request.ServiceTier ?? "default"
|
||||
};
|
||||
}
|
||||
|
||||
public static List<ChatCompletionChoice> ToChoices(this AgentRunResponse agentRunResponse)
|
||||
{
|
||||
var chatCompletionChoices = new List<ChatCompletionChoice>();
|
||||
var index = 0;
|
||||
|
||||
var finishReason = (agentRunResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse)
|
||||
? chatResponse.FinishReason.ToString()
|
||||
: "stop"; // "stop" is a natural stop point; returning this by-default
|
||||
|
||||
foreach (var message in agentRunResponse.Messages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
ChoiceMessage? choiceMessage = content switch
|
||||
{
|
||||
// text
|
||||
TextContent textContent => new()
|
||||
{
|
||||
Content = textContent.Text
|
||||
},
|
||||
|
||||
// image, see how MessageContentPartConverter packs the content types
|
||||
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new()
|
||||
{
|
||||
Content = imageContent.Base64Data.ToString()
|
||||
},
|
||||
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new()
|
||||
{
|
||||
Content = urlContent.Uri.ToString()
|
||||
},
|
||||
|
||||
// audio
|
||||
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new()
|
||||
{
|
||||
Audio = new()
|
||||
{
|
||||
Data = audioContent.Base64Data.ToString(),
|
||||
Id = audioContent.Name,
|
||||
//Transcript = ,
|
||||
//ExpiresAt = ,
|
||||
},
|
||||
},
|
||||
|
||||
// file (neither audio nor image)
|
||||
DataContent fileContent => new()
|
||||
{
|
||||
Content = fileContent.Base64Data.ToString()
|
||||
},
|
||||
HostedFileContent fileContent => new()
|
||||
{
|
||||
Content = fileContent.FileId
|
||||
},
|
||||
|
||||
// function call
|
||||
FunctionCallContent functionCallContent => new()
|
||||
{
|
||||
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
|
||||
},
|
||||
|
||||
// function result. ChatCompletions dont provide the results of function result per API reference
|
||||
FunctionResultContent functionResultContent => null,
|
||||
|
||||
// ignore
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (choiceMessage is null)
|
||||
{
|
||||
// not supported, but expected content type.
|
||||
continue;
|
||||
}
|
||||
|
||||
choiceMessage.Role = message.Role.Value;
|
||||
choiceMessage.Annotations = content.Annotations?.ToChoiceMessageAnnotations();
|
||||
|
||||
var choice = new ChatCompletionChoice
|
||||
{
|
||||
Index = index++,
|
||||
Message = choiceMessage,
|
||||
FinishReason = finishReason
|
||||
};
|
||||
|
||||
chatCompletionChoices.Add(choice);
|
||||
}
|
||||
}
|
||||
|
||||
return chatCompletionChoices;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts UsageDetails to CompletionUsage.
|
||||
/// </summary>
|
||||
/// <param name="usage">The usage details to convert.</param>
|
||||
/// <returns>A CompletionUsage object with zeros if usage is null.</returns>
|
||||
public static CompletionUsage ToCompletionUsage(this UsageDetails? usage)
|
||||
{
|
||||
if (usage == null)
|
||||
{
|
||||
return CompletionUsage.Zero;
|
||||
}
|
||||
|
||||
var cachedTokens = usage.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cachedInputToken) ?? false
|
||||
? (int)cachedInputToken
|
||||
: 0;
|
||||
var reasoningTokens =
|
||||
usage.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoningToken) ?? false
|
||||
? (int)reasoningToken
|
||||
: 0;
|
||||
|
||||
return new CompletionUsage
|
||||
{
|
||||
PromptTokens = (int)(usage.InputTokenCount ?? 0),
|
||||
PromptTokensDetails = new() { CachedTokens = cachedTokens },
|
||||
CompletionTokens = (int)(usage.OutputTokenCount ?? 0),
|
||||
CompletionTokensDetails = new() { ReasoningTokens = reasoningTokens },
|
||||
TotalTokens = (int)(usage.TotalTokenCount ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
public static IList<ChoiceMessageAnnotation> ToChoiceMessageAnnotations(this IList<AIAnnotation> annotations)
|
||||
{
|
||||
var result = new List<ChoiceMessageAnnotation>();
|
||||
foreach (var annotation in annotations.OfType<CitationAnnotation>())
|
||||
{
|
||||
if (annotation is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// may point to mulitple regions in the AIContent.
|
||||
// we need to unroll another loop for regions then -> chatCompletions only point to single region per annotation
|
||||
|
||||
var regions = annotation.AnnotatedRegions?.OfType<TextSpanAnnotatedRegion>().Where(x => x.StartIndex is not null && x.EndIndex is not null);
|
||||
if (regions is not null)
|
||||
{
|
||||
foreach (var region in regions)
|
||||
{
|
||||
result.Add(new()
|
||||
{
|
||||
AnnotationUrlCitation = new AnnotationUrlCitation
|
||||
{
|
||||
Url = annotation.Url?.ToString(),
|
||||
Title = annotation.Title,
|
||||
StartIndex = region.StartIndex,
|
||||
EndIndex = region.EndIndex
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(new()
|
||||
{
|
||||
AnnotationUrlCitation = new AnnotationUrlCitation
|
||||
{
|
||||
Url = annotation.Url?.ToString(),
|
||||
Title = annotation.Title
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallContent functionCall)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = functionCall.CallId,
|
||||
Function = new()
|
||||
{
|
||||
Name = functionCall.Name,
|
||||
Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
AllowOutOfOrderMetadataProperties = true,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(CreateChatCompletion))]
|
||||
[JsonSerializable(typeof(StopSequences))]
|
||||
[JsonSerializable(typeof(ChatCompletion))]
|
||||
[JsonSerializable(typeof(ChatCompletionRequestMessage))]
|
||||
[JsonSerializable(typeof(IList<ChatCompletionRequestMessage>))]
|
||||
[JsonSerializable(typeof(MessageContent))]
|
||||
[JsonSerializable(typeof(MessageContentPart))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<MessageContentPart>))]
|
||||
[JsonSerializable(typeof(TextContentPart))]
|
||||
[JsonSerializable(typeof(ImageContentPart))]
|
||||
[JsonSerializable(typeof(AudioContentPart))]
|
||||
[JsonSerializable(typeof(FileContentPart))]
|
||||
[JsonSerializable(typeof(ChatCompletionChoice))]
|
||||
[JsonSerializable(typeof(IList<ChatCompletionChoice>))]
|
||||
[JsonSerializable(typeof(ChoiceMessage))]
|
||||
[JsonSerializable(typeof(ChoiceMessageAnnotation))]
|
||||
[JsonSerializable(typeof(ChoiceMessageAudio))]
|
||||
[JsonSerializable(typeof(ChoiceMessageFunctionCall))]
|
||||
[JsonSerializable(typeof(ChoiceMessageToolCall))]
|
||||
[JsonSerializable(typeof(AnnotationUrlCitation))]
|
||||
[JsonSerializable(typeof(ChatCompletionChoiceChunk))]
|
||||
[JsonSerializable(typeof(IList<ChatCompletionChoiceChunk>))]
|
||||
[JsonSerializable(typeof(ChatCompletionChunk))]
|
||||
[JsonSerializable(typeof(ChatCompletionDelta))]
|
||||
[JsonSerializable(typeof(ToolChoice))]
|
||||
[JsonSerializable(typeof(AllowedToolsChoice))]
|
||||
[JsonSerializable(typeof(AllowedToolsConfiguration))]
|
||||
[JsonSerializable(typeof(ToolDefinition))]
|
||||
[JsonSerializable(typeof(IList<ToolDefinition>))]
|
||||
[JsonSerializable(typeof(FunctionReference))]
|
||||
[JsonSerializable(typeof(FunctionToolChoice))]
|
||||
[JsonSerializable(typeof(CustomToolChoice))]
|
||||
[JsonSerializable(typeof(CustomToolObject))]
|
||||
[JsonSerializable(typeof(ResponseFormat))]
|
||||
[JsonSerializable(typeof(TextResponseFormat))]
|
||||
[JsonSerializable(typeof(JsonSchemaResponseFormat))]
|
||||
[JsonSerializable(typeof(JsonSchemaConfiguration))]
|
||||
[JsonSerializable(typeof(JsonObjectResponseFormat))]
|
||||
[JsonSerializable(typeof(Tool))]
|
||||
[JsonSerializable(typeof(IList<Tool>))]
|
||||
[JsonSerializable(typeof(FunctionTool))]
|
||||
[JsonSerializable(typeof(FunctionDefinition))]
|
||||
[JsonSerializable(typeof(CustomTool))]
|
||||
[JsonSerializable(typeof(CustomToolProperties))]
|
||||
[JsonSerializable(typeof(CustomToolFormat))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext;
|
||||
+3
-3
@@ -2,12 +2,12 @@
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for JSON serialization.
|
||||
/// </summary>
|
||||
internal static class ResponsesJsonSerializerOptions
|
||||
internal static class ChatCompletionsJsonSerializerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default JSON serializer options.
|
||||
@@ -16,7 +16,7 @@ internal static class ResponsesJsonSerializerOptions
|
||||
|
||||
private static JsonSerializerOptions Create()
|
||||
{
|
||||
JsonSerializerOptions options = new(ResponsesJsonContext.Default.Options);
|
||||
JsonSerializerOptions options = new(ChatCompletionsJsonContext.Default.Options);
|
||||
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
|
||||
|
||||
internal static class ChatClientAgentRunOptionsConverter
|
||||
{
|
||||
private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request)
|
||||
{
|
||||
ChatOptions chatOptions = new()
|
||||
{
|
||||
Temperature = request.Temperature,
|
||||
MaxOutputTokens = request.MaxCompletionTokens,
|
||||
FrequencyPenalty = request.FrequencyPenalty,
|
||||
PresencePenalty = request.PresencePenalty,
|
||||
Seed = request.Seed,
|
||||
TopP = request.TopP,
|
||||
StopSequences = request.Stop?.SequenceList ?? [],
|
||||
ResponseFormat = request.ResponseFormat?.ToChatResponseFormat()
|
||||
};
|
||||
|
||||
if (request.ToolChoice is not null)
|
||||
{
|
||||
chatOptions.ToolMode = request.ToolChoice.ToChatToolMode();
|
||||
}
|
||||
|
||||
if (request.Tools?.Count > 0)
|
||||
{
|
||||
chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList();
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
ChatOptions = chatOptions
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat)
|
||||
{
|
||||
if (responseFormat.IsText)
|
||||
{
|
||||
return ChatResponseFormat.Text;
|
||||
}
|
||||
if (responseFormat.IsJsonObject)
|
||||
{
|
||||
return ChatResponseFormat.Json;
|
||||
}
|
||||
if (responseFormat.IsJsonSchema)
|
||||
{
|
||||
var schema = responseFormat.JsonSchema.JsonSchema;
|
||||
return ChatResponseFormat.ForJsonSchema(schema.Schema, schema.Name, schema.Description);
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(responseFormat));
|
||||
}
|
||||
|
||||
private static AITool ToAITool(this Tool tool)
|
||||
{
|
||||
if (tool is FunctionTool functionTool)
|
||||
{
|
||||
var function = functionTool.Function;
|
||||
return AIFunctionFactory.CreateDeclaration(function.Name, function.Description, function.Parameters ?? s_emptyJson);
|
||||
}
|
||||
if (tool is CustomTool customTool)
|
||||
{
|
||||
var custom = customTool.Custom;
|
||||
return new CustomAITool(custom.Name, custom.Description, custom.Format?.AdditionalProperties);
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(tool));
|
||||
}
|
||||
|
||||
private static ChatToolMode? ToChatToolMode(this ToolChoice toolChoice)
|
||||
{
|
||||
if (toolChoice.IsMode)
|
||||
{
|
||||
return toolChoice.Mode switch
|
||||
{
|
||||
"auto" => ChatToolMode.Auto,
|
||||
"none" => ChatToolMode.None,
|
||||
"required" => ChatToolMode.RequireAny,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (toolChoice.IsAllowedTools)
|
||||
{
|
||||
var mode = toolChoice.AllowedTools.AllowedTools.Mode;
|
||||
return mode switch
|
||||
{
|
||||
"auto" => ChatToolMode.Auto,
|
||||
"required" => ChatToolMode.RequireAny,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (toolChoice.IsFunctionTool)
|
||||
{
|
||||
var function = toolChoice.FunctionTool.Function;
|
||||
return ChatToolMode.RequireSpecific(function.Name);
|
||||
}
|
||||
|
||||
if (toolChoice.IsCustomTool)
|
||||
{
|
||||
var custom = toolChoice.CustomTool.Custom;
|
||||
return ChatToolMode.RequireSpecific(custom.Name);
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(toolChoice));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
|
||||
|
||||
internal static class MessageContentPartConverter
|
||||
{
|
||||
private static string AudioFormatToMediaType(string format) =>
|
||||
format.Equals("mp3", StringComparison.OrdinalIgnoreCase) ? "audio/mpeg" :
|
||||
format.Equals("wav", StringComparison.OrdinalIgnoreCase) ? "audio/wav" :
|
||||
format.Equals("opus", StringComparison.OrdinalIgnoreCase) ? "audio/opus" :
|
||||
format.Equals("aac", StringComparison.OrdinalIgnoreCase) ? "audio/aac" :
|
||||
format.Equals("flac", StringComparison.OrdinalIgnoreCase) ? "audio/flac" :
|
||||
format.Equals("pcm16", StringComparison.OrdinalIgnoreCase) ? "audio/pcm" :
|
||||
"audio/*";
|
||||
public static AIContent? ToAIContent(MessageContentPart part)
|
||||
{
|
||||
return part switch
|
||||
{
|
||||
// text
|
||||
TextContentPart textPart => new TextContent(textPart.Text),
|
||||
|
||||
// image
|
||||
ImageContentPart imagePart when !string.IsNullOrEmpty(imagePart.UrlOrData) =>
|
||||
imagePart.UrlOrData.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|
||||
? new DataContent(imagePart.UrlOrData, "image/*")
|
||||
: new UriContent(imagePart.Url, ImageUriToMediaType(imagePart.Url)),
|
||||
|
||||
// audio
|
||||
AudioContentPart audioPart =>
|
||||
new DataContent(audioPart.InputAudio.Data, AudioFormatToMediaType(audioPart.InputAudio.Format)),
|
||||
|
||||
// file
|
||||
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId)
|
||||
=> new HostedFileContent(filePart.File.FileId),
|
||||
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileData)
|
||||
=> new DataContent(filePart.File.FileData, "application/octet-stream") { Name = filePart.File.Filename },
|
||||
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ImageUriToMediaType(Uri uri)
|
||||
{
|
||||
string absoluteUri = uri.AbsoluteUri;
|
||||
return
|
||||
absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" :
|
||||
absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
|
||||
absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
|
||||
absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" :
|
||||
absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" :
|
||||
absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" :
|
||||
"image/*";
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chat completion response returned by the model, based on the provided input.
|
||||
/// </summary>
|
||||
internal sealed record ChatCompletion
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique identifier for the chat completion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
[JsonRequired]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The object type, which is always "chat.completion".
|
||||
/// </summary>
|
||||
[JsonPropertyName("object")]
|
||||
public string Object { get; init; } = "chat.completion";
|
||||
|
||||
/// <summary>
|
||||
/// The Unix timestamp (in seconds) of when the chat completion was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created")]
|
||||
[JsonRequired]
|
||||
public required long Created { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The model used for the chat completion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")]
|
||||
[JsonRequired]
|
||||
public required string Model { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of chat completion choices. Can be more than one if n is greater than 1.
|
||||
/// </summary>
|
||||
[JsonPropertyName("choices")]
|
||||
[JsonRequired]
|
||||
public required IList<ChatCompletionChoice> Choices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Usage statistics for the completion request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public CompletionUsage? Usage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service_tier")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ServiceTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// This fingerprint represents the backend configuration that the model runs with.
|
||||
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
|
||||
/// </summary>
|
||||
[JsonPropertyName("system_fingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SystemFingerprint { get; init; }
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a choice in a chat completion response.
|
||||
/// </summary>
|
||||
internal sealed record ChatCompletionChoice
|
||||
{
|
||||
/// <summary>
|
||||
/// The index of the choice in the list of choices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reason the model stopped generating tokens.
|
||||
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
|
||||
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool,
|
||||
/// or function_call (deprecated) if the model called a function.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finish_reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FinishReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A chat completion message generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required ChoiceMessage Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chat completion message generated by the model.
|
||||
/// </summary>
|
||||
internal sealed record ChoiceMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// The role of the author of this message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of annotations for this message. Currently used for web search citations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IList<ChoiceMessageAnnotation>? Annotations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The contents of the message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The refusal message generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("refusal")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Refusal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If the audio output modality is requested, this object contains data about the audio response from the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("audio")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ChoiceMessageAudio? Audio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("function_call")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tool calls generated by the model, such as function calls.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tool_calls")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audio output data in a chat completion message.
|
||||
/// </summary>
|
||||
internal sealed record ChoiceMessageAudio
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64 encoded audio bytes generated by the model, in the format specified in the request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public string? Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public int ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this audio response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transcript of the audio generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transcript")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Transcript { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated. The name and arguments of a function that should be called, as generated by the model.
|
||||
/// </summary>
|
||||
internal sealed record ChoiceMessageFunctionCall
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the function to call.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The arguments to call the function with, as generated by the model in JSON format.
|
||||
/// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema.
|
||||
/// Validate the arguments in your code before calling your function.
|
||||
/// </summary>
|
||||
[JsonPropertyName("arguments")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Arguments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tool call generated by the model.
|
||||
/// </summary>
|
||||
internal sealed record ChoiceMessageToolCall
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the tool call.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of the tool.
|
||||
/// </summary>
|
||||
public string Type => "function";
|
||||
|
||||
/// <summary>
|
||||
/// The function that the model called.
|
||||
/// </summary>
|
||||
[JsonPropertyName("function")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ChoiceMessageFunctionCall? Function { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An annotation for a message, used for web search citations.
|
||||
/// </summary>
|
||||
internal sealed record ChoiceMessageAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of annotation. Always 'url_citation' for web search results.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type => "url_citation";
|
||||
|
||||
/// <summary>
|
||||
/// The URL citation details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url_citation")]
|
||||
public required AnnotationUrlCitation AnnotationUrlCitation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A citation to a URL for a web search result.
|
||||
/// </summary>
|
||||
internal sealed record AnnotationUrlCitation
|
||||
{
|
||||
/// <summary>
|
||||
/// The character index in the message content where the citation ends.
|
||||
/// </summary>
|
||||
[JsonPropertyName("end_index")]
|
||||
public int? EndIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The character index in the message content where the citation starts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("start_index")]
|
||||
public int? StartIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The title of the cited resource.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The URL of the cited resource.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of chat completion response returned by the model, based on the provided input.
|
||||
/// </summary>
|
||||
internal sealed record ChatCompletionChunk
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique identifier for the chat completion. Each chunk has the same ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
[JsonRequired]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of chat completion choices. Can be more than one if n is greater than 1.
|
||||
/// </summary>
|
||||
[JsonPropertyName("choices")]
|
||||
[JsonRequired]
|
||||
public required IList<ChatCompletionChoiceChunk> Choices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The object type, which is always "chat.completion.chunk".
|
||||
/// </summary>
|
||||
[JsonPropertyName("object")]
|
||||
public string Object => "chat.completion.chunk";
|
||||
|
||||
/// <summary>
|
||||
/// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created")]
|
||||
[JsonRequired]
|
||||
public required long Created { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The model to generate the completion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")]
|
||||
[JsonRequired]
|
||||
public required string Model { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Usage statistics for the completion request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public CompletionUsage? Usage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service_tier")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ServiceTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// This fingerprint represents the backend configuration that the model runs with.
|
||||
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
|
||||
/// </summary>
|
||||
[JsonPropertyName("system_fingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SystemFingerprint { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatCompletionChoiceChunk
|
||||
{
|
||||
/// <summary>
|
||||
/// The index of the choice in the list of choices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reason the model stopped generating tokens.
|
||||
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
|
||||
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, or function_call (deprecated) if the model called a function.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finish_reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FinishReason { get; init; }
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public required ChatCompletionDelta Delta { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatCompletionDelta
|
||||
{
|
||||
/// <summary>
|
||||
/// The contents of the chunk message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The refusal message generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("refusal")]
|
||||
public string? Refusal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The role of the author of this message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("function_call")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
|
||||
|
||||
[JsonPropertyName("tool_calls")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user