Compare commits

..
Author SHA1 Message Date
Dmytro StrukandGitHub 573aff4825 Updated chatkit package version (#1950) 2025-11-06 00:13:52 +00:00
9b471cc479 Add simple workflow sample that mixes agents and executors (#1946)
* Add workflow mix agent executor samples

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 22:40:25 +00:00
Evan MattsonandGitHub afd8b7ecb4 Export sample agents (#1927) 2025-11-05 22:26:18 +00:00
Dmytro StrukandGitHub 327c304339 .NET: Python: Updated package versions (#1944)
* Updated .NET version

* Updated Python package versions

* Updated changelog
2025-11-05 22:20:31 +00:00
Tao ChenandGitHub 3f835c8118 .NET: AgentHostExecutor should use agent DisplayName as Executor ID (#1840)
* AgentHostExecutor should use agent DisplayName as Executor ID

* Address comments

* Remove unintended changes

* Remove unintended changes

* Fix tests and address comments
2025-11-05 22:16:53 +00:00
Jacob AlberandGitHub 0bf6d437d8 .NET: [BREAKING] .NET: Add Workflow on-Build() Orphan Validation (#1943)
* fix: Workflow Validation

* adds orphan validation to workflow builder
* adds tests for workflow validation
* expands on the underlying reasoning why type validation is not supported

* fixup: CodeGen template
2025-11-05 22:12:27 +00:00
d701e796cb Python: [BREAKING] Replaced AIProjectClient with AgentsClient in Foundry (#1936)
* Replaced AIProjectClient with AgentsClient in Foundry

* Update python/samples/getting_started/observability/azure_ai_agent_observability.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/samples/getting_started/observability/azure_ai_chat_client_with_observability.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Small fix

* Removed TODO item

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 21:42:36 +00:00
Reuben BondandGitHub 8855bfb065 .NET: Add DevUI package for .NET (#1603)
* Implement DevUI

* Review feedback

* Fix build
2025-11-05 20:02:48 +00:00
Jacob AlberandGitHub 94a5ba3448 .NET: [BREAKING] refactor: Normalize WorkflowBuilder APIs (#1935)
* [BREAKING] refactor: Normalize WorkflowBuilder APIs

* "partitioner" => "assigner"
* normalize ordering so sources always to the left of targets for edges
* normalize parameter ordering so sources and targets are always first arguments
* remove `params` (users should use collection expressions instead)

* refactor: Align name with Python
2025-11-05 19:50:01 +00:00
Reuben BondandGitHub 33f84f9ed2 .NET: Improve fidelity of OpenAI Responses server and add Conversations (#1907)
* Improve fidelity of OpenAI Responses server and add Conversations

* Merge

* nit

* Undo prior change

* Undo prior change

* Review feedback

* Review feedback

* Fix test

* Use simpler JsonDocument approach for polymorphic deserialization

* More review feedback

* dotnet format
2025-11-05 18:46:19 +00:00
Eduard van ValkenburgandGitHub e2282ebe42 Python: fix missing packaging dependency (#1929)
* fix missing packaging dependency

* add aiohttp to azureai
2025-11-05 18:09:54 +00:00
Jacob AlberandGitHub b8a55dccb4 [BREAKING] refactor: Fix unintuitive binding of StreamAsync (#1930)
In #1551 we added a mechanism to open a Streaming workflow run without providing any input. This caused unintuitive behaviour when passing a string as input without providing a runId, resulting in the input being misinterpreted as the runId and the workflow not executing.

As well, the name caused confusion about why the Workflow was not running when using the input-less StreamAsync (since the Workflow cannot run without any messages to drive its execution).
2025-11-05 17:26:46 +00:00
Dmytro StrukandGitHub 14aee7e334 Python: Added parameter to disable agent cleanup in AzureAIAgentClient (#1882)
* Removed automatic agent cleanup in AzureAIAgentClient

* Revert "Removed automatic agent cleanup in AzureAIAgentClient"

This reverts commit 89846c7212.

* Exposed boolean flag to control deletion behavior

* Update sample
2025-11-05 16:56:19 +00:00
Javier Calvarro NelsonandGitHub b03a4fb95e .NET: AG-UI support for .NET (#1776)
* Initial plan

* Infrastructure setup

* Plan for minimal client

* Plan update

* Basic agentic chat

* cleanup

* Cleanups

* More cleanups

* Cleanups

* More cleanups

* Test plan

* Sample

* Fix streaming and error handling

* Fix notifications

* Cleanups

* cleanup sample

* Additional tests

* Additional tests

* Run dotnet format

* Remove unnecessary files

* Mark packages as non packable

* Fix build

* Address feedback

* Fix build

* Fix remaining warnings

* Feedback

* Feedback and cleanup

* Cleanup

* Cleanups

* Cleanups

* Cleanups

* Retrieve existing messages from the store to send them along the way and update the sample client

* Run dotnet format

* Add ADR for AG-UI

* Switch to use the SG and use a convention for run ids

* Cleanup MapAGUI API

* Fix formatting

* Fix solution

* Fix solution
2025-11-05 15:51:37 +00:00
Jeff HandleyandGitHub 77d882e2b4 Fix workflow lookup with AddAsAIAgent(name) when name differs from workflow name (#1925) 2025-11-05 15:31:36 +00:00
Roger BarretoandGitHub 6ca907f23f .NET: [Breaking Change] Moving MAAI.AzureAI V1 Package -> MAAI.AzureAI.Persistent (V1) (#1902)
* Update AzureAI -> AzureAI.Persistent

* Fix sample reference
2025-11-05 13:28:00 +00:00
westeyandGitHub 5e38c63455 .NET: [BREAKING] Simplify TextSearchProvider construction and improve Mem0Provider scoping. (#1905)
* Simplify TextSearchProvider construction and improve Mem0Provider scoping

* Fixing indentation.
2025-11-05 13:00:18 +00:00
Korolev DmitryandGitHub bb8ef466de .NET: Improve fidelity of OpenAI ChatCompletions Hosting (#1785)
* rename, support json serialization

* wip

* non-streaming

* streaming?

* proper streaming types

* comments + fix audio parse

* copilot suggestions

* proper stopsequences type

* build options as i could

* annotations

* proper generation of Id for chatcompletions

* string length as in chatcompletions api ref

* image url

* support tools

* rework API

* introduce tests for chatcompletions

* function calling / serialization tests / fixes

* more tests and coverage

* fix format

* sort usings

* nit

* address PR comments

* nits
2025-11-05 09:55:26 +00:00
Eduard van ValkenburgandGitHub 54db13c22f Python: add support for Python 3.14 (#1904)
* add tests for py3.14 and add classifier

* remove macos

* allow openai v2
2025-11-05 09:42:39 +00:00
Eduard van ValkenburgandGitHub 51b32ed1ac Python: Updates to Tools (#1835)
* updated tool samples

* mypy and readme fixes

* updated call logic

* added function invocation config

* added include detailed error

* added tests

* updated FRC exception handling

* updated tests

* fix oai test

* fix name in sample

* imporoved tests coverage and removed some dead code paths
2025-11-05 08:33:19 +00:00
393 changed files with 31893 additions and 7173 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+95
View File
@@ -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
+3 -1
View File
@@ -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" />
+16 -2
View File
@@ -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" />
@@ -272,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" />
@@ -289,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" />
@@ -297,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" />
+3 -3
View File
@@ -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();
+202
View File
@@ -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>
@@ -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>
@@ -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();
@@ -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();
@@ -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)
});
@@ -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.
@@ -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>
@@ -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>
@@ -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();
}
@@ -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>
@@ -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>
@@ -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>
@@ -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) =>
{
@@ -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>
@@ -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();
}
@@ -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();
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -11,7 +11,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>
@@ -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>
@@ -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);
}
@@ -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>
@@ -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);
}
}
}
@@ -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)
}
};
}
}
@@ -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;
@@ -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;
@@ -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));
}
}
@@ -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/*";
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a message in a chat completion request.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "role", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(DeveloperMessage), "developer")]
[JsonDerivedType(typeof(SystemMessage), "system")]
[JsonDerivedType(typeof(UserMessage), "user")]
[JsonDerivedType(typeof(AssistantMessage), "assistant")]
[JsonDerivedType(typeof(ToolMessage), "tool")]
[JsonDerivedType(typeof(FunctionMessage), "function")]
internal abstract record ChatCompletionRequestMessage
{
/// <summary>
/// The role of the content.
/// </summary>
[JsonIgnore]
public abstract string Role { get; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
public required MessageContent Content { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
public virtual ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
else if (this.Content.IsContents)
{
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
return new ChatMessage(ChatRole.User, aiContents!);
}
throw new InvalidOperationException("MessageContent has no value");
}
}
/// <summary>
/// A developer message in a chat completion request.
/// Developer messages are used to provide instructions to the model at the system level.
/// </summary>
internal sealed record DeveloperMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "developer";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A system message in a chat completion request.
/// System messages provide high-level instructions for the conversation.
/// </summary>
internal sealed record SystemMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "system";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A user message in a chat completion request.
/// User messages represent input from the end user.
/// </summary>
internal sealed record UserMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "user";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// An assistant message in a chat completion request.
/// Assistant messages represent previous responses from the model, used in multi-turn conversations.
/// </summary>
internal sealed record AssistantMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "assistant";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A tool message in a chat completion request.
/// Tool messages contain the result of a tool call made by the assistant.
/// </summary>
internal sealed record ToolMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "tool";
/// <summary>
/// Tool call that this message is responding to.
/// </summary>
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; set; }
}
/// <summary>
/// Deprecated. A function message in a chat completion request.
/// Function messages have been replaced by tool messages.
/// </summary>
internal sealed record FunctionMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "function";
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
public override ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
throw new InvalidOperationException("FunctionMessage Content must be text");
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents usage statistics for a chat completion request.
/// </summary>
internal sealed record CompletionUsage
{
public static CompletionUsage Zero { get; } = new()
{
CompletionTokens = 0,
PromptTokens = 0,
TotalTokens = 0,
CompletionTokensDetails = new()
{
AcceptedPredictionTokens = 0,
AudioTokens = 0,
ReasoningTokens = 0,
RejectedPredictionTokens = 0
},
PromptTokensDetails = new()
{
AudioTokens = 0,
CachedTokens = 0
},
};
/// <summary>
/// Number of tokens in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public int? CompletionTokens { get; set; }
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int? PromptTokens { get; set; }
/// <summary>
/// Total number of tokens used in the request (prompt + completion).
/// </summary>
[JsonPropertyName("total_tokens")]
public int? TotalTokens { get; set; }
/// <summary>
/// Breakdown of tokens used in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens_details")]
public required CompletionTokensDetails CompletionTokensDetails { get; set; }
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens_details")]
public required PromptTokensDetails PromptTokensDetails { get; set; }
public static CompletionUsage operator +(CompletionUsage left, CompletionUsage right) => new()
{
CompletionTokens = left.CompletionTokens + right.CompletionTokens,
PromptTokens = left.PromptTokens + right.PromptTokens,
TotalTokens = left.TotalTokens + right.TotalTokens,
CompletionTokensDetails = left.CompletionTokensDetails + right.CompletionTokensDetails,
PromptTokensDetails = left.PromptTokensDetails + right.PromptTokensDetails
};
}
/// <summary>
/// Breakdown of tokens used in a completion.
/// </summary>
internal sealed record CompletionTokensDetails
{
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion.
/// </summary>
[JsonPropertyName("accepted_prediction_tokens")]
public int AcceptedPredictionTokens { get; set; }
/// <summary>
/// Audio input tokens generated by the model.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Tokens generated by the model for reasoning.
/// </summary>
[JsonPropertyName("reasoning_tokens")]
public int ReasoningTokens { get; set; }
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion.
/// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing,
/// output, and context window limits.
/// </summary>
[JsonPropertyName("rejected_prediction_tokens")]
public int RejectedPredictionTokens { get; set; }
public static CompletionTokensDetails operator +(CompletionTokensDetails left, CompletionTokensDetails right) => new()
{
AcceptedPredictionTokens = left.AcceptedPredictionTokens + right.AcceptedPredictionTokens,
AudioTokens = left.AudioTokens + right.AudioTokens,
ReasoningTokens = left.ReasoningTokens + right.ReasoningTokens,
RejectedPredictionTokens = left.RejectedPredictionTokens + right.RejectedPredictionTokens
};
}
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
internal sealed record PromptTokensDetails
{
/// <summary>
/// Audio input tokens present in the prompt.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Cached tokens present in the prompt.
/// </summary>
[JsonPropertyName("cached_tokens")]
public int CachedTokens { get; set; }
public static PromptTokensDetails operator +(PromptTokensDetails left, PromptTokensDetails right) => new()
{
AudioTokens = left.AudioTokens + right.AudioTokens,
CachedTokens = left.CachedTokens + right.CachedTokens
};
}
@@ -0,0 +1,258 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Request to create a chat completion.
/// </summary>
internal sealed record CreateChatCompletion
{
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
[JsonPropertyName("messages")]
[JsonRequired]
public required IList<ChatCompletionRequestMessage> Messages { get; set; }
/// <summary>
/// Model ID used to generate the response, like `gpt-4o` or `o3`.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; set; }
/// <summary>
/// Parameters for audio output. Required when audio output is requested with modalities: ["audio"].
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Audio { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far.
/// </summary>
[JsonPropertyName("frequency_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? FrequencyPenalty { get; set; }
/// <summary>
/// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of ToolChoice.")]
public object? FunctionCall { get; set; }
/// <summary>
/// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for.
/// </summary>
[JsonPropertyName("functions")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of Tools.")]
public IList<object>? Functions { get; set; }
/// <summary>
/// Modify the likelihood of specified tokens appearing in the completion.
/// </summary>
[JsonPropertyName("logit_bias")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, int>? LogitBias { get; set; }
/// <summary>
/// Whether to return log probabilities of the output tokens or not.
/// </summary>
[JsonPropertyName("logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Logprobs { get; set; }
/// <summary>
/// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens.
/// </summary>
[JsonPropertyName("max_completion_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxCompletionTokens { get; set; }
/// <summary>
/// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens)
/// </summary>
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Use MaxCompletionTokens instead. This property is deprecated and not compatible with o-series models.")]
public int? MaxTokens { get; set; }
/// <summary>
/// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional
/// information about the object in a structured format, and querying for objects via API or the dashboard.
/// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.
/// </summary>
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Metadata { get; set; }
/// <summary>
/// Types of content modalities the model can output. Can include "text" and/or "audio".
/// </summary>
[JsonPropertyName("modalities")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<string>? Modalities { get; set; }
/// <summary>
/// How many chat completion choices to generate for each input message.
/// </summary>
[JsonPropertyName("n")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? N { get; set; }
/// <summary>
/// Whether to enable parallel function calling during tool use.
/// </summary>
[JsonPropertyName("parallel_tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; set; }
/// <summary>
/// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time.
/// </summary>
[JsonPropertyName("prediction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Prediction { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
/// </summary>
[JsonPropertyName("presence_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? PresencePenalty { get; set; }
/// <summary>
/// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates.
/// </summary>
[JsonPropertyName("prompt_cache_key")]
public string? PromptCacheKey { get; init; }
/// <summary>
/// The reasoning effort level for o-series models. Can be "low", "medium", or "high".
/// </summary>
[JsonPropertyName("reasoning_effort")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningEffort { get; set; }
/// <summary>
/// An object specifying the format that the model must output.
/// </summary>
[JsonPropertyName("response_format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies.
/// The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address,
/// in order to avoid sending us any identifying information.
/// </summary>
[JsonPropertyName("safety_identifier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SafetyIdentifier { get; set; }
/// <summary>
/// If specified, the system will make a best effort to sample deterministically.
/// </summary>
[JsonPropertyName("seed")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Seed { get; set; }
/// <summary>
/// Specifies the processing type used for serving the request.
/// If set to 'auto', the request will be processed with the service tier configured in the Project settings.
/// If set to 'default', the request will be processed with standard pricing and performance.
/// If set to 'flex' or 'priority', the request will be processed with the corresponding service tier.
/// Defaults to 'auto'.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; set; }
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StopSequences? Stop { get; set; }
/// <summary>
/// Whether or not to store the output of this chat completion request for use in model distillation or evals products.
/// </summary>
[JsonPropertyName("store")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Store { get; set; }
/// <summary>
/// If set to true, the model response data will be streamed to the client using server-sent events.
/// </summary>
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Stream { get; set; }
/// <summary>
/// Options for streaming response. Only set this when you set stream: true.
/// </summary>
[JsonPropertyName("stream_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? StreamOptions { get; set; }
/// <summary>
/// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random,
/// while lower values like 0.2 will make it more focused and deterministic.
/// We generally recommend altering this or top_p but not both. Defaults to 1.
/// </summary>
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? Temperature { get; set; }
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolChoice? ToolChoice { get; set; }
/// <summary>
/// A list of tools the model may call. Can include custom tools or function tools.
/// </summary>
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<Tool>? Tools { get; set; }
/// <summary>
/// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position.
/// </summary>
[JsonPropertyName("top_logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? TopLogprobs { get; set; }
/// <summary>
/// An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of
/// the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.
/// We generally recommend altering this or temperature but not both.
/// </summary>
[JsonPropertyName("top_p")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? TopP { get; set; }
/// <summary>
/// Level of detail in the model's output. Can be "standard" or "verbose".
/// </summary>
[JsonPropertyName("verbosity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Verbosity { get; set; } = "medium";
/// <summary>
/// Web search tool configuration for searching the web for relevant results.
/// </summary>
[JsonPropertyName("web_search_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? WebSearchOptions { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More