Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Chris
2025-11-10 08:42:17 -08:00
committed by GitHub
161 changed files with 17581 additions and 5794 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.2" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.6" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.6.0" />
+11 -8
View File
@@ -20,6 +20,7 @@
</Folder>
<Folder Name="/Samples/AGUIClientServer/">
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
@@ -48,8 +49,7 @@
<File Path="samples/GettingStarted/Agents/README.md" />
<Project Path="samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Agent_Step03.1_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Agent_Step03.2_UsingFunctionTools_FromOpenAPI.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj" />
@@ -59,20 +59,22 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step13_Memory/Agent_Step13_Memory.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.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/AgentWithMemory/">
<File Path="samples/GettingStarted/AgentWithMemory/README.md" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.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" />
@@ -81,7 +83,8 @@
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/ModelContextProtocol/">
<File Path="samples/GettingStarted/ModelContextProtocol/README.md" />
+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).251107.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251107.1</PackageVersion>
<GitTag>1.0.0-preview.251107.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251110.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251110.1</PackageVersion>
<GitTag>1.0.0-preview.251110.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
-1
View File
@@ -1 +0,0 @@
launchSettings.json
@@ -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>b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21</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,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
[JsonSerializable(typeof(WeatherInfo))]
[JsonSerializable(typeof(Recipe))]
[JsonSerializable(typeof(Ingredient))]
[JsonSerializable(typeof(RecipeResponse))]
internal sealed partial class AGUIDojoServerSerializerContext : JsonSerializerContext;
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ChatClient = OpenAI.Chat.ChatClient;
namespace AGUIDojoServer;
internal static class ChatClientAgentFactory
{
private static AzureOpenAIClient? s_azureOpenAIClient;
private static string? s_deploymentName;
public static void Initialize(IConfiguration configuration)
{
string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
s_azureOpenAIClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential());
}
public static ChatClientAgent CreateAgenticChat()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticChat",
description: "A simple chat agent using Azure OpenAI");
}
public static ChatClientAgent CreateBackendToolRendering()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "BackendToolRenderer",
description: "An agent that can render backend tools using Azure OpenAI",
tools: [AIFunctionFactory.Create(
GetWeather,
name: "get_weather",
description: "Get the weather for a given location.",
AGUIDojoServerSerializerContext.Default.Options)]);
}
public static ChatClientAgent CreateHumanInTheLoop()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "HumanInTheLoopAgent",
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
}
public static ChatClientAgent CreateToolBasedGenerativeUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "ToolBasedGenerativeUIAgent",
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
}
public static ChatClientAgent CreateAgenticUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticUIAgent",
description: "An agent that generates agentic user interfaces using Azure OpenAI");
}
public static AIAgent CreateSharedState(JsonSerializerOptions options)
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().CreateAIAgent(
name: "SharedStateAgent",
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
return new SharedStateAgent(baseAgent, options);
}
[Description("Get the weather for a given location.")]
private static WeatherInfo GetWeather([Description("The location to get the weather for.")] string location) => new()
{
Temperature = 20,
Conditions = "sunny",
Humidity = 50,
WindSpeed = 10,
FeelsLike = 25
};
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Ingredient
{
[JsonPropertyName("icon")]
public string Icon { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public string Amount { get; set; } = string.Empty;
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUIDojoServer;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Extensions.Options;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpLogging(logging =>
{
logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody
| HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody;
logging.RequestBodyLogLimit = int.MaxValue;
logging.ResponseBodyLogLimit = int.MaxValue;
});
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUI();
WebApplication app = builder.Build();
app.UseHttpLogging();
// Initialize the factory
ChatClientAgentFactory.Initialize(app.Configuration);
// Map the AG-UI agent endpoints for different scenarios
app.MapAGUI("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat());
app.MapAGUI("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering());
app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop());
app.MapAGUI("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI());
app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI());
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions));
await app.RunAsync();
public partial class Program { }
@@ -0,0 +1,12 @@
{
"profiles": {
"AGUIDojoServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5018"
}
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Recipe
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
[JsonPropertyName("cooking_time")]
public string CookingTime { get; set; } = string.Empty;
[JsonPropertyName("special_preferences")]
public List<string> SpecialPreferences { get; set; } = [];
[JsonPropertyName("ingredients")]
public List<Ingredient> Ingredients { get; set; } = [];
[JsonPropertyName("instructions")]
public List<string> Instructions { get; set; } = [];
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
#pragma warning disable CA1812 // Used for the JsonSchema response format
internal sealed class RecipeResponse
#pragma warning restore CA1812
{
[JsonPropertyName("recipe")]
public Recipe Recipe { get; set; } = new();
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoServer;
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateSharedState")]
internal sealed class SharedStateAgent : DelegatingAIAgent
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
: base(innerAgent)
{
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions ||
!properties.TryGetValue("ag_ui_state", out JsonElement state))
{
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
var firstRunOptions = new ChatClientAgentRunOptions
{
ChatOptions = chatRunOptions.ChatOptions.Clone(),
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
ContinuationToken = chatRunOptions.ContinuationToken,
ChatClientFactory = chatRunOptions.ChatClientFactory,
};
// Configure JSON schema response format for structured state output
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<RecipeResponse>(
schemaName: "RecipeResponse",
schemaDescription: "A response containing a recipe with title, skill level, cooking time, preferences, ingredients, and instructions");
ChatMessage stateUpdateMessage = new(
ChatRole.System,
[
new TextContent("Here is the current state in JSON format:"),
new TextContent(state.GetRawText()),
new TextContent("The new state is:")
]);
var firstRunMessages = messages.Append(stateUpdateMessage);
var allUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, thread, firstRunOptions, cancellationToken).ConfigureAwait(false))
{
allUpdates.Add(update);
// Yield all non-text updates (tool calls, etc.)
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
if (hasNonTextContent)
{
yield return update;
}
}
var response = allUpdates.ToAgentRunResponse();
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(stateBytes, "application/json")]
};
}
else
{
yield break;
}
var secondRunMessages = messages.Concat(response.Messages).Append(
new ChatMessage(
ChatRole.System,
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class WeatherInfo
{
[JsonPropertyName("temperature")]
public int Temperature { get; init; }
[JsonPropertyName("conditions")]
public string Conditions { get; init; } = string.Empty;
[JsonPropertyName("humidity")]
public int Humidity { get; init; }
[JsonPropertyName("wind_speed")]
public int WindSpeed { get; init; }
[JsonPropertyName("feelsLike")]
public int FeelsLike { get; init; }
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
}
}
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,12 @@
{
"profiles": {
"AGUIServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5100;https://localhost:5101"
}
}
}
@@ -107,8 +107,8 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents
app.UseExceptionHandler();
// attach a2a with simple message communication
app.MapA2A(agentName: "pirate", path: "/a2a/pirate");
app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", agentCard: new()
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
{
Name = "Knights and Knaves",
Description = "An agent that helps you solve the knights and knaves puzzle.",
@@ -0,0 +1,9 @@
# Agent Framework Retrieval Augmented Generation (RAG)
These samples show how to create an agent with the Agent Framework that uses Memory to remember previous conversations or facts from previous conversations.
|Sample|Description|
|---|---|
|[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.|
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent.
// This sample shows how to use Qdrant with a custom schema to add retrieval augmented generation (RAG) capabilities to an AI agent.
// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context.
@@ -1,11 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
// capabilities to an AI agent. The provider runs a search against an external knowledge base
// capabilities to an AI agent. This shows a mock implementation of a search function,
// which can be replaced with any custom search logic to query any external knowledge base.
// The provider invokes the custom search function
// before each model invocation and injects the results into the model context.
// Also see the AgentWithRAG folder for more advanced RAG scenarios.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
@@ -5,4 +5,5 @@ These samples show how to create an agent with the Agent Framework that uses Ret
|Sample|Description|
|---|---|
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|[RAG with external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.|
|[RAG with Vector Store and custom schema](./AgentWithRAG_Step02_CustomVectorStoreRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a vector store. It also uses a custom schema for the documents stored in the vector store.|
|[RAG with custom RAG data source](./AgentWithRAG_Step03_CustomRAGDataSource/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a custom RAG data source.|
@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenAPISpec.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,354 +0,0 @@
{
"openapi": "3.0.1",
"info": {
"title": "Github Versions API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.github.com"
}
],
"components": {
"schemas": {
"basic-error": {
"title": "Basic Error",
"description": "Basic Error",
"type": "object",
"properties": {
"message": {
"type": "string"
},
"documentation_url": {
"type": "string"
},
"url": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"label": {
"title": "Label",
"description": "Color-coded labels help you categorize and filter your issues (just like labels in Gmail).",
"type": "object",
"properties": {
"id": {
"description": "Unique identifier for the label.",
"type": "integer",
"format": "int64",
"example": 208045946
},
"node_id": {
"type": "string",
"example": "MDU6TGFiZWwyMDgwNDU5NDY="
},
"url": {
"description": "URL for the label",
"example": "https://api.github.com/repositories/42/labels/bug",
"type": "string",
"format": "uri"
},
"name": {
"description": "The name of the label.",
"example": "bug",
"type": "string"
},
"description": {
"description": "Optional description of the label, such as its purpose.",
"type": "string",
"example": "Something isn't working",
"nullable": true
},
"color": {
"description": "6-character hex code, without the leading #, identifying the color",
"example": "FFFFFF",
"type": "string"
},
"default": {
"description": "Whether this label comes by default in a new repository.",
"type": "boolean",
"example": true
}
},
"required": [
"id",
"node_id",
"url",
"name",
"description",
"color",
"default"
]
},
"tag": {
"title": "Tag",
"description": "Tag",
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "v0.1"
},
"commit": {
"type": "object",
"properties": {
"sha": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
}
},
"required": [
"sha",
"url"
]
},
"zipball_url": {
"type": "string",
"format": "uri",
"example": "https://github.com/octocat/Hello-World/zipball/v0.1"
},
"tarball_url": {
"type": "string",
"format": "uri",
"example": "https://github.com/octocat/Hello-World/tarball/v0.1"
},
"node_id": {
"type": "string"
}
},
"required": [
"name",
"node_id",
"commit",
"zipball_url",
"tarball_url"
]
}
},
"examples": {
"label-items": {
"value": [
{
"id": 208045946,
"node_id": "MDU6TGFiZWwyMDgwNDU5NDY=",
"url": "https://api.github.com/repos/octocat/Hello-World/labels/bug",
"name": "bug",
"description": "Something isn't working",
"color": "f29513",
"default": true
},
{
"id": 208045947,
"node_id": "MDU6TGFiZWwyMDgwNDU5NDc=",
"url": "https://api.github.com/repos/octocat/Hello-World/labels/enhancement",
"name": "enhancement",
"description": "New feature or request",
"color": "a2eeef",
"default": false
}
]
},
"tag-items": {
"value": [
{
"name": "v0.1",
"commit": {
"sha": "c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc",
"url": "https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc"
},
"zipball_url": "https://github.com/octocat/Hello-World/zipball/v0.1",
"tarball_url": "https://github.com/octocat/Hello-World/tarball/v0.1",
"node_id": "MDQ6VXNlcjE="
}
]
}
},
"parameters": {
"owner": {
"name": "owner",
"description": "The account owner of the repository. The name is not case sensitive.",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"repo": {
"name": "repo",
"description": "The name of the repository without the `.git` extension. The name is not case sensitive.",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"per-page": {
"name": "per_page",
"description": "The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
"in": "query",
"schema": {
"type": "integer",
"default": 30
}
},
"page": {
"name": "page",
"description": "The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
}
},
"responses": {
"not_found": {
"description": "Resource not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/basic-error"
}
}
}
}
},
"headers": {
"link": {
"example": "<https://api.github.com/resource?page=2>; rel=\"next\", <https://api.github.com/resource?page=5>; rel=\"last\"",
"schema": {
"type": "string"
}
}
}
},
"paths": {
"/repos/{owner}/{repo}/tags": {
"get": {
"summary": "List repository tags",
"description": "",
"tags": [
"repos"
],
"operationId": "repos/list-tags",
"externalDocs": {
"description": "API method documentation",
"url": "https://docs.github.com/rest/repos/repos#list-repository-tags"
},
"parameters": [
{
"$ref": "#/components/parameters/owner"
},
{
"$ref": "#/components/parameters/repo"
},
{
"$ref": "#/components/parameters/per-page"
},
{
"$ref": "#/components/parameters/page"
}
],
"responses": {
"200": {
"description": "Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/tag"
}
},
"examples": {
"default": {
"$ref": "#/components/examples/tag-items"
}
}
}
},
"headers": {
"Link": {
"$ref": "#/components/headers/link"
}
}
}
},
"x-github": {
"githubCloudOnly": false,
"enabledForGitHubApps": true,
"category": "repos",
"subcategory": "repos"
}
}
},
"/repos/{owner}/{repo}/labels": {
"get": {
"summary": "List labels for a repository",
"description": "Lists all labels for a repository.",
"tags": [
"issues"
],
"operationId": "issues/list-labels-for-repo",
"externalDocs": {
"description": "API method documentation",
"url": "https://docs.github.com/rest/issues/labels#list-labels-for-a-repository"
},
"parameters": [
{
"$ref": "#/components/parameters/owner"
},
{
"$ref": "#/components/parameters/repo"
},
{
"$ref": "#/components/parameters/per-page"
},
{
"$ref": "#/components/parameters/page"
}
],
"responses": {
"200": {
"description": "Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/label"
}
},
"examples": {
"default": {
"$ref": "#/components/examples/label-items"
}
}
}
},
"headers": {
"Link": {
"$ref": "#/components/headers/link"
}
}
},
"404": {
"$ref": "#/components/responses/not_found"
}
},
"x-github": {
"githubCloudOnly": false,
"enabledForGitHubApps": true,
"category": "issues",
"subcategory": "labels"
}
}
}
}
}
@@ -1,33 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatClientAgent with function tools provided via an OpenAPI spec.
// It uses functionality from Semantic Kernel to parse the OpenAPI spec and create function tools to use with the Agent Framework Agent.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.OpenApi;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Load the OpenAPI Spec from a file.
KernelPlugin plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("github", "OpenAPISpec.json");
// Convert the Semantic Kernel plugin to Agent Framework function tools.
// This requires a dummy Kernel instance, since KernelFunctions cannot execute without one.
Kernel kernel = new();
List<AITool> tools = plugin.Select(x => x.WithKernel(kernel)).Cast<AITool>().ToList();
// Create the chat client and agent, and provide the OpenAPI function tools to the agent.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(instructions: "You are a helpful assistant", tools: tools);
// Run the agent with the OpenAPI function tools.
Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github."));
@@ -28,8 +28,8 @@ Before you begin, ensure you have the following prerequisites:
|---|---|
|[Running a simple agent](./Agent_Step01_Running/)|This sample demonstrates how to create and run a basic agent with instructions|
|[Multi-turn conversation with a simple agent](./Agent_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a simple agent|
|[Using function tools with a simple agent](./Agent_Step03.1_UsingFunctionTools/)|This sample demonstrates how to use function tools with a simple agent|
|[Using OpenAPI function tools with a simple agent](./Agent_Step03.2_UsingFunctionTools_FromOpenAPI/)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a simple agent|
|[Using function tools with a simple agent](./Agent_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a simple agent|
|[Using OpenAPI function tools with a simple agent](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a simple agent (note that this sample is in the Semantic Kernel repository)|
|[Using function tools with approvals](./Agent_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution|
|[Structured output with a simple agent](./Agent_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a simple agent|
|[Persisted conversations with a simple agent](./Agent_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later. This is useful for cases where an agent is hosted in a stateless service|
@@ -39,14 +39,11 @@ Before you begin, ensure you have the following prerequisites:
|[Exposing a simple agent as MCP tool](./Agent_Step10_AsMcpTool/)|This sample demonstrates how to expose an agent as an MCP tool|
|[Using images with a simple agent](./Agent_Step11_UsingImages/)|This sample demonstrates how to use image multi-modality with an AI agent|
|[Exposing a simple agent as a function tool](./Agent_Step12_AsFunctionTool/)|This sample demonstrates how to expose an agent as a function tool|
|[Using memory with an agent](./Agent_Step13_Memory/)|This sample demonstrates how to create a simple memory component and use it with an agent|
|[Background responses with tools and persistence](./Agent_Step13_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
|[Using middleware with an agent](./Agent_Step14_Middleware/)|This sample demonstrates how to use middleware with an agent|
|[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent|
|[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally|
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|[Adding RAG with text search](./Agent_Step18_TextSearchRag/)|This sample demonstrates how to enrich agent responses with retrieval augmented generation using the text search provider|
|[Using Mem0-backed memory](./Agent_Step19_Mem0Provider/)|This sample demonstrates how to use the Mem0Provider to persist and recall memories across conversations|
|[Background responses with tools and persistence](./Agent_Step20_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
## Running the samples from the console
@@ -64,13 +64,14 @@ internal static class Program
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
}).AddAsAIAgent();
if (builder.Environment.IsDevelopment())
{
builder.AddDevUI();
}
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (builder.Environment.IsDevelopment())
{
app.MapDevUI();
@@ -0,0 +1,13 @@
{
"profiles": {
"DevUI_Step01_BasicUsage": {
"commandName": "Project",
"launchUrl": "devui",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:50516;http://localhost:50518"
}
}
}
@@ -63,17 +63,23 @@ To add DevUI to your ASP.NET Core application:
.AddAsAIAgent();
```
3. Add DevUI services and map the endpoint:
3. Add OpenAI services and map the endpoints for OpenAI and DevUI:
```csharp
builder.AddDevUI();
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
app.MapDevUI();
// Add required endpoints
app.MapEntities();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (builder.Environment.IsDevelopment())
{
// Map DevUI endpoint to /devui
app.MapDevUI();
}
app.Run();
```
+10 -7
View File
@@ -38,19 +38,22 @@ builder.Services.AddChatClient(chatClient);
// Register your agents
builder.AddAIAgent("my-agent", "You are a helpful assistant.");
// Add DevUI services
builder.AddDevUI();
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
// Map the DevUI endpoint
app.MapDevUI();
// Add required endpoints
app.MapEntities();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (builder.Environment.IsDevelopment())
{
// Map DevUI endpoint to /devui
app.MapDevUI();
}
app.Run();
```
+2
View File
@@ -9,6 +9,8 @@ of the agent framework.
|---|---|
|[Agents](./Agents/README.md)|Step by step instructions for getting started with agents|
|[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers|
|[Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md)|Adding Retrieval Augmented Generation (RAG) capabilities to your agents.|
|[Agents With Memory](./AgentWithMemory/README.md)|Adding Memory capabilities to your agents.|
|[A2A](./A2A/README.md)|Getting started with A2A (Agent-to-Agent) specific features|
|[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents|
|[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents|
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -152,6 +153,8 @@ public sealed class AGUIChatClient : DelegatingChatClient
private sealed class AGUIChatClientHandler : IChatClient
{
private static readonly MediaTypeHeaderValue s_json = new("application/json");
private readonly AGUIHttpService _httpService;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
@@ -199,6 +202,9 @@ public sealed class AGUIChatClient : DelegatingChatClient
var threadId = ExtractTemporaryThreadId(messagesList) ??
ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
// Extract state from the last message if it contains DataContent with application/json
JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList);
// Create the input for the AGUI service
var input = new RunAgentInput
{
@@ -207,6 +213,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
ThreadId = threadId,
RunId = runId,
Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
State = state,
};
// Add tools if provided
@@ -300,6 +307,51 @@ public sealed class AGUIChatClient : DelegatingChatClient
return threadId;
}
// Extract state from the last message's DataContent with application/json media type
// and remove that message from the list
private JsonElement ExtractAndRemoveStateFromMessages(List<ChatMessage> messagesList)
{
if (messagesList.Count == 0)
{
return default;
}
// Check the last message for state DataContent
ChatMessage lastMessage = messagesList[messagesList.Count - 1];
for (int i = 0; i < lastMessage.Contents.Count; i++)
{
if (lastMessage.Contents[i] is DataContent dataContent &&
MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) &&
mediaType.Equals(s_json))
{
// Deserialize the state JSON directly from UTF-8 bytes
try
{
JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize(
dataContent.Data.Span,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!;
// Remove the DataContent from the message contents
lastMessage.Contents.RemoveAt(i);
// If no contents remain, remove the entire message
if (lastMessage.Contents.Count == 0)
{
messagesList.RemoveAt(messagesList.Count - 1);
}
return stateElement;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex);
}
}
}
return default;
}
public void Dispose()
{
// No resources to dispose
@@ -316,7 +368,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
}
}
private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
{
public FunctionCallContent FunctionCallContent { get; } = functionCall;
}
@@ -27,4 +27,8 @@ internal static class AGUIEventTypes
public const string ToolCallEnd = "TOOL_CALL_END";
public const string ToolCallResult = "TOOL_CALL_RESULT";
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
}
@@ -44,6 +44,8 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallArgsEvent))]
[JsonSerializable(typeof(ToolCallEndEvent))]
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -57,6 +59,6 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(decimal))]
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
@@ -46,6 +46,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -95,8 +96,14 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case ToolCallResultEvent toolCallResult:
JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
break;
case StateSnapshotEvent stateSnapshot:
JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent)));
break;
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
@@ -18,6 +19,9 @@ namespace Microsoft.Agents.AI.AGUI.Shared;
internal static class ChatResponseUpdateAGUIExtensions
{
private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
private static readonly MediaTypeHeaderValue? s_json = new("application/json");
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<BaseEvent> events,
JsonSerializerOptions jsonSerializerOptions,
@@ -70,11 +74,73 @@ internal static class ChatResponseUpdateAGUIExtensions
case ToolCallResultEvent toolCallResult:
yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
break;
// State snapshot events
case StateSnapshotEvent stateSnapshot:
if (stateSnapshot.Snapshot.HasValue)
{
yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions);
}
break;
case StateDeltaEvent stateDelta:
if (stateDelta.Delta.HasValue)
{
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
}
}
}
private class TextMessageBuilder()
private static ChatResponseUpdate CreateStateSnapshotUpdate(
StateSnapshotEvent stateSnapshot,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot.Snapshot!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_snapshot"] = true
}
};
}
private static ChatResponseUpdate CreateStateDeltaUpdate(
StateDeltaEvent stateDelta,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateDelta.Delta!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_delta"] = true
}
};
}
private sealed class TextMessageBuilder()
{
private ChatRole _currentRole;
private string? _currentMessageId;
@@ -154,7 +220,7 @@ internal static class ChatResponseUpdateAGUIExtensions
};
}
private class ToolCallBuilder
private sealed class ToolCallBuilder
{
private string? _conversationId;
private string? _responseId;
@@ -348,6 +414,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
{
// State snapshot event
yield return new StateSnapshotEvent
{
#if NET472 || NETSTANDARD2_0
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType))
{
// State snapshot patch event must be a valid JSON patch,
// but its not up to us to validate that here.
yield return new StateDeltaEvent
{
#if NET472 || NETSTANDARD2_0
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else
{
// Text content event
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
#if NET472 || NETSTANDARD2_0
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
#else
Delta = Encoding.UTF8.GetString(dataContent.Data.Span)
#endif
};
}
}
}
}
}
@@ -0,0 +1,21 @@
// 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 StateDeltaEvent : BaseEvent
{
public StateDeltaEvent()
{
this.Type = AGUIEventTypes.StateDelta;
}
[JsonPropertyName("delta")]
public JsonElement? Delta { get; set; }
}
@@ -0,0 +1,21 @@
// 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 StateSnapshotEvent : BaseEvent
{
public StateSnapshotEvent()
{
this.Type = AGUIEventTypes.StateSnapshot;
}
[JsonPropertyName("snapshot")]
public JsonElement? Snapshot { get; set; }
}
@@ -9,23 +9,23 @@ namespace Microsoft.Agents.AI.DevUI;
/// </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>
/// <remarks>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </remarks>
/// <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>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
@@ -33,8 +33,6 @@ public static class DevUIExtensions
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapEntities();
group.MapOpenAIConversations();
group.MapOpenAIResponses();
return group;
}
@@ -24,14 +24,16 @@ 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();
}
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (builder.Environment.IsDevelopment())
{
// Map DevUI endpoint to /devui
@@ -83,7 +83,16 @@ public static class AIAgentExtensions
{
// A2A SDK assigns the url on its own
// we can help user if they did not set Url explicitly.
agentCard.Url ??= context;
if (string.IsNullOrEmpty(agentCard.Url))
{
var agentCardUrl = context.TrimEnd('/');
if (!context.EndsWith("/v1/card", StringComparison.Ordinal))
{
agentCardUrl += "/v1/card";
}
agentCard.Url = agentCardUrl;
}
return Task.FromResult(agentCard);
};
@@ -44,22 +44,27 @@ public static class AGUIEndpointRouteBuilderExtensions
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var agent = aiAgent;
var clientTools = input.Tools?.AsAITools().ToList();
ChatClientAgentRunOptions? runOptions = null;
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
if (clientTools?.Count > 0)
// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
{
runOptions = new ChatClientAgentRunOptions
ChatOptions = new ChatOptions
{
ChatOptions = new ChatOptions
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
Tools = clientTools
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
};
}
}
};
var events = agent.RunStreamingAsync(
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
messages,
options: runOptions,
cancellationToken: cancellationToken)
@@ -18,7 +18,7 @@ internal abstract record Tool
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public abstract string Type { get; }
}
@@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "function";
/// <summary>
@@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "custom";
/// <summary>
@@ -24,6 +24,10 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
this._agent = agent;
}
public ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default) => ValueTask.FromResult<ResponseError?>(null);
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
@@ -56,7 +56,7 @@ internal static class AgentRunResponseExtensions
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = request.Metadata is IReadOnlyDictionary<string, string> metadata ? new Dictionary<string, string>(metadata) : [],
Model = request.Agent?.Name ?? request.Model,
Model = request.Model,
Output = output,
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
@@ -64,7 +64,7 @@ internal static class AgentRunResponseExtensions
PromptCacheKey = request.PromptCacheKey,
Reasoning = request.Reasoning,
SafetyIdentifier = request.SafetyIdentifier,
ServiceTier = request.ServiceTier ?? "default",
ServiceTier = request.ServiceTier,
Status = ResponseStatus.Completed,
Store = request.Store ?? true,
Temperature = request.Temperature ?? 1.0,
@@ -165,7 +165,7 @@ internal static class AgentRunResponseUpdateExtensions
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = request.Metadata != null ? new Dictionary<string, string>(request.Metadata) : [],
Model = request.Agent?.Name ?? request.Model,
Model = request.Model,
Output = outputs?.ToList() ?? [],
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
@@ -13,8 +13,9 @@ using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// <summary>
/// Response executor that routes requests to hosted AIAgent services based on the model or agent.name parameter.
/// Response executor that routes requests to hosted AIAgent services based on agent.name or metadata["entity_id"].
/// This executor resolves agents from keyed services registered via AddAIAgent().
/// The model field is reserved for actual model names and is never used for entity/agent identification.
/// </summary>
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
@@ -37,16 +38,46 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
this._logger = logger;
}
/// <inheritdoc/>
public ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
// Extract agent name from agent.name or model parameter
string? agentName = GetAgentName(request);
if (string.IsNullOrEmpty(agentName))
{
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "missing_required_parameter",
Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request."
});
}
// Validate that the agent can be resolved
AIAgent? agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is null)
{
this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName);
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "agent_not_found",
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
});
}
return ValueTask.FromResult<ResponseError?>(null);
}
/// <inheritdoc/>
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Validate and resolve agent synchronously to ensure validation errors are thrown immediately
AIAgent agent = this.ResolveAgent(request);
// Create options with properties from the request
string agentName = GetAgentName(request)!;
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
var chatOptions = new ChatOptions
{
ConversationId = request.Conversation?.Id,
@@ -57,8 +88,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
ModelId = request.Model,
};
var options = new ChatClientAgentRunOptions(chatOptions);
// Convert input to chat messages
var messages = new List<ChatMessage>();
foreach (var inputMessage in request.Input.GetInputMessages())
@@ -66,7 +95,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
messages.Add(inputMessage.ToChatMessage());
}
// Use the extension method to convert streaming updates to streaming response events
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
{
@@ -75,41 +103,20 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
}
/// <summary>
/// Resolves an agent from the service provider based on the request.
/// Extracts the agent name for a request from the agent.name property, falling back to metadata["entity_id"].
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>The resolved AIAgent instance.</returns>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
private AIAgent ResolveAgent(CreateResponse request)
/// <returns>The agent name.</returns>
private static string? GetAgentName(CreateResponse request)
{
// Extract agent name from agent.name or model parameter
var agentName = request.Agent?.Name ?? request.Model;
if (string.IsNullOrEmpty(agentName))
string? agentName = request.Agent?.Name;
// Fall back to metadata["entity_id"] if agent.name is not present
if (string.IsNullOrEmpty(agentName) && request.Metadata?.TryGetValue("entity_id", out string? entityId) == true)
{
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
agentName = entityId;
}
// Resolve the keyed agent service
try
{
return this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
}
catch (InvalidOperationException ex)
{
this._logger.LogError(ex, "Failed to resolve agent with name '{AgentName}'", agentName);
throw new InvalidOperationException($"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent().", ex);
}
}
/// <summary>
/// Validates that the agent can be resolved without actually resolving it.
/// This allows early validation before starting async execution.
/// </summary>
/// <param name="request">The create response request.</param>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
public void ValidateAgent(CreateResponse request)
{
// Use the same logic as ResolveAgent but don't return the agent
_ = this.ResolveAgent(request);
return agentName;
}
}
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -12,6 +13,16 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// </summary>
internal interface IResponseExecutor
{
/// <summary>
/// Validates a create response request before execution.
/// </summary>
/// <param name="request">The create response request to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="ResponseError"/> if validation fails, null if validation succeeds.</returns>
ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes a response generation request and returns streaming events.
/// </summary>
@@ -18,6 +18,17 @@ internal interface IResponsesService
/// Default limit for list operations.
/// </summary>
const int DefaultListLimit = 20;
/// <summary>
/// Validates a create response request before execution.
/// </summary>
/// <param name="request">The create response request to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A ResponseError if validation fails, null if validation succeeds.</returns>
ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a model response for the given input.
/// </summary>
@@ -147,18 +147,27 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
this._conversationStorage = conversationStorage;
}
public async ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
!string.IsNullOrEmpty(request.PreviousResponseId))
{
return new ResponseError
{
Code = "invalid_request",
Message = "Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'."
};
}
return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
}
public async Task<Response> CreateResponseAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
ValidateRequest(request);
// Validate agent resolution early for HostedAgentResponseExecutor
if (this._executor is HostedAgentResponseExecutor hostedExecutor)
{
hostedExecutor.ValidateAgent(request);
}
if (request.Stream == true)
{
throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
@@ -189,8 +198,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
CreateResponse request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ValidateRequest(request);
if (request.Stream == false)
{
throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead.");
@@ -342,15 +349,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
});
}
private static void ValidateRequest(CreateResponse request)
{
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
!string.IsNullOrEmpty(request.PreviousResponseId))
{
throw new InvalidOperationException("Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'.");
}
}
private ResponseState InitializeResponse(string responseId, CreateResponse request)
{
var metadata = request.Metadata ?? [];
@@ -371,7 +369,7 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = metadata,
Model = request.Model ?? "default",
Model = request.Model,
Output = [],
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
@@ -182,7 +182,9 @@ internal sealed class ResponseInputJsonConverter : JsonConverter<ResponseInput>
return messages is not null ? ResponseInput.FromMessages(messages) : null;
}
throw new JsonException($"Unexpected token type for ResponseInput: {reader.TokenType}");
throw new JsonException(
"ResponseInput must be either a string or an array of messages. " +
$"Objects are not supported. Received token type: {reader.TokenType}");
}
/// <inheritdoc/>
@@ -34,6 +34,21 @@ internal sealed class ResponsesHttpHandler
[FromQuery] bool? stream,
CancellationToken cancellationToken)
{
// Validate the request first
ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
if (validationError is not null)
{
return Results.BadRequest(new ErrorResponse
{
Error = new ErrorDetails
{
Message = validationError.Message,
Type = "invalid_request_error",
Code = validationError.Code
}
});
}
try
{
// Handle streaming vs non-streaming
@@ -55,45 +70,24 @@ internal sealed class ResponsesHttpHandler
request,
cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Mutually exclusive"))
{
// Return OpenAI-style error for mutual exclusivity violations
return Results.BadRequest(new ErrorResponse
return response.Status switch
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error",
Code = "mutually_exclusive_parameters"
}
});
ResponseStatus.Failed when response.Error is { } error => Results.Problem(
detail: error.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: error.Code ?? "Internal Server Error"),
ResponseStatus.Failed => Results.Problem(),
ResponseStatus.Queued => Results.Accepted(value: response),
_ => Results.Ok(response)
};
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("does not exist"))
catch (Exception ex)
{
// Return OpenAI-style error for not found errors
return Results.NotFound(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error"
}
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
{
// Return OpenAI-style error for missing required parameters
return Results.BadRequest(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error",
Code = "missing_required_parameter"
}
});
// Return InternalServerError for unexpected exceptions
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Internal Server Error");
}
}
@@ -1282,6 +1282,312 @@ public sealed class AGUIAgentTests
// AG-UI requirement: full history on every turn (which happens when ConversationId is null for FunctionInvokingChatClient)
Assert.True(captureHandler.RequestWasMade);
}
[Fact]
public async Task GetStreamingResponseAsync_ExtractsStateFromDataContent_AndRemovesStateMessageAsync()
{
// Arrange
var stateData = new { counter = 42, status = "active" };
string stateJson = JsonSerializer.Serialize(stateData);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(42, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("active", captureHandler.CapturedState.Value.GetProperty("status").GetString());
// Verify state message was removed - only user message should be in the request
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNoStateDataContent_SendsEmptyStateAsync()
{
// Arrange
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
}
[Fact]
public async Task GetStreamingResponseAsync_WithMalformedStateJson_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
byte[] invalidJson = System.Text.Encoding.UTF8.GetBytes("{invalid json");
var dataContent = new DataContent(invalidJson, "application/json");
using HttpClient httpClient = this.CreateMockHttpClient([]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act & Assert
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
});
Assert.Contains("Failed to deserialize state JSON", ex.Message);
}
[Fact]
public async Task GetStreamingResponseAsync_WithEmptyStateObject_SendsEmptyObjectAsync()
{
// Arrange
var emptyState = new { };
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(JsonValueKind.Object, captureHandler.CapturedState.Value.ValueKind);
}
[Fact]
public async Task GetStreamingResponseAsync_OnlyProcessesDataContentFromLastMessage_IgnoresEarlierOnesAsync()
{
// Arrange
var oldState = new { counter = 10 };
string oldStateJson = JsonSerializer.Serialize(oldState);
byte[] oldStateBytes = System.Text.Encoding.UTF8.GetBytes(oldStateJson);
var oldDataContent = new DataContent(oldStateBytes, "application/json");
var newState = new { counter = 20 };
string newStateJson = JsonSerializer.Serialize(newState);
byte[] newStateBytes = System.Text.Encoding.UTF8.GetBytes(newStateJson);
var newDataContent = new DataContent(newStateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.System, [oldDataContent]),
new ChatMessage(ChatRole.User, "Second message"),
new ChatMessage(ChatRole.System, [newDataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
// Should use the new state from the last message
Assert.Equal(20, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
// Should have removed only the last state message
Assert.Equal(3, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNonJsonMediaType_IgnoresDataContentAsync()
{
// Arrange
byte[] imageData = System.Text.Encoding.UTF8.GetBytes("fake image data");
var dataContent = new DataContent(imageData, "image/png");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, [new TextContent("Hello"), dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
// Message should not be removed since it's not state
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_RoundTripState_PreservesJsonStructureAsync()
{
// Arrange - Server returns state snapshot
var returnedState = new { counter = 100, nested = new { value = "test" } };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(returnedState);
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Done" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act - First turn: receive state
DataContent? receivedStateContent = null;
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
if (update.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"))
{
receivedStateContent = (DataContent)update.Contents.First(c => c is DataContent);
}
}
// Second turn: send the received state back
Assert.NotNull(receivedStateContent);
messages.Add(new ChatMessage(ChatRole.System, [receivedStateContent]));
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert - Verify the round-tripped state
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(100, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("test", captureHandler.CapturedState.Value.GetProperty("nested").GetProperty("value").GetString());
}
[Fact]
public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentWithAdditionalPropertiesAsync()
{
// Arrange
var state = new { sessionId = "abc123", step = 5 };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(state);
using HttpClient httpClient = this.CreateMockHttpClient(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties!["is_state_snapshot"]!);
DataContent dataContent = (DataContent)stateUpdate.Contents[0];
Assert.Equal("application/json", dataContent.MediaType);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString());
Assert.Equal(5, deserializedState.GetProperty("step").GetInt32());
}
}
internal sealed class TestDelegatingHandler : DelegatingHandler
@@ -1376,3 +1682,58 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
};
}
}
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
{
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
public bool RequestWasMade { get; private set; }
public JsonElement? CapturedState { get; private set; }
public int CapturedMessageCount { get; private set; }
public void AddResponse(BaseEvent[] events)
{
this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events)));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.RequestWasMade = true;
// Capture the state and message count from the request
#if NET472 || NETSTANDARD2_0
string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
#else
string requestBody = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#endif
RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput);
if (input != null)
{
if (input.State.ValueKind != JsonValueKind.Undefined && input.State.ValueKind != JsonValueKind.Null)
{
this.CapturedState = input.State;
}
this.CapturedMessageCount = input.Messages.Count();
}
if (this._responseFactories.Count == 0)
{
throw new InvalidOperationException("No more responses configured for StateCapturingTestDelegatingHandler.");
}
var factory = this._responseFactories.Dequeue();
return await factory(request);
}
private static HttpResponseMessage CreateResponse(BaseEvent[] events)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(sseContent)
};
}
}
@@ -369,4 +369,412 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
Assert.Equal("call_2", functionCalls[1].CallId);
Assert.Equal("Tool2", functionCalls[1].Name);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync()
{
// Arrange
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.Equal(ChatRole.Assistant, stateUpdate.Role);
Assert.Equal("thread1", stateUpdate.ConversationId);
Assert.Equal("run1", stateUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
Assert.Equal("application/json", dataContent.MediaType);
// Verify the JSON content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32());
Assert.Equal("active", deserializedState.GetProperty("status").GetString());
// Verify additional properties
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync()
{
// Arrange
JsonElement emptyState = JsonSerializer.SerializeToElement(new { });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = emptyState },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
Assert.Equal("{}", jsonText);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync()
{
// Arrange
var complexState = new
{
user = new { name = "Alice", age = 30 },
items = new[] { "item1", "item2", "item3" },
metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 }
};
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState);
List<BaseEvent> events =
[
new StateSnapshotEvent { Snapshot = stateSnapshot }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First();
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement roundTrippedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString());
Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32());
Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength());
Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync()
{
// Arrange
JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." },
new TextMessageEndEvent { MessageId = "msg1" },
new StateSnapshotEvent { Snapshot = state },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent));
}
#region State Delta Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync()
{
// Arrange - Create JSON Patch operations (RFC 6902)
JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "replace", path = "/counter", value = 43 },
new { op = "add", path = "/newField", value = "test" }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = stateDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
Assert.Equal(ChatRole.Assistant, deltaUpdate.Role);
Assert.Equal("thread1", deltaUpdate.ConversationId);
Assert.Equal("run1", deltaUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(deltaUpdate.Contents[0]);
Assert.Equal("application/json-patch+json", dataContent.MediaType);
// Verify the JSON Patch content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedDelta = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind);
Assert.Equal(2, deserializedDelta.GetArrayLength());
// Verify first operation
JsonElement firstOp = deserializedDelta[0];
Assert.Equal("replace", firstOp.GetProperty("op").GetString());
Assert.Equal("/counter", firstOp.GetProperty("path").GetString());
Assert.Equal(43, firstOp.GetProperty("value").GetInt32());
// Verify second operation
JsonElement secondOp = deserializedDelta[1];
Assert.Equal("add", secondOp.GetProperty("op").GetString());
Assert.Equal("/newField", secondOp.GetProperty("path").GetString());
Assert.Equal("test", secondOp.GetProperty("value").GetString());
// Verify additional properties
Assert.NotNull(deltaUpdate.AdditionalProperties);
Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert - Only run started and finished should be present
Assert.Equal(2, updates.Count);
Assert.IsType<ChatResponseUpdate>(updates[0]); // Run started
Assert.IsType<ChatResponseUpdate>(updates[1]); // Run finished
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync()
{
// Arrange - Empty JSON Patch array is valid
JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty<object>());
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = emptyDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync()
{
// Arrange
JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } });
JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = delta1 },
new StateDeltaEvent { Delta = delta2 },
new StateDeltaEvent { Delta = delta3 },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList();
Assert.Equal(3, deltaUpdates.Count);
}
[Fact]
public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync()
{
// Arrange - Create a ChatResponseUpdate with JSON Patch DataContent
JsonElement patchOps = JsonSerializer.SerializeToElement(new object[]
{
new { op = "remove", path = "/oldField" },
new { op = "add", path = "/newField", value = "newValue" }
});
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps);
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
MessageId = "msg1"
}
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(deltaEvent);
Assert.NotNull(deltaEvent.Delta);
Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind);
// Verify patch operations
JsonElement delta = deltaEvent.Delta.Value;
Assert.Equal(2, delta.GetArrayLength());
Assert.Equal("remove", delta[0].GetProperty("op").GetString());
Assert.Equal("/oldField", delta[0].GetProperty("path").GetString());
Assert.Equal("add", delta[1].GetProperty("op").GetString());
Assert.Equal("/newField", delta[1].GetProperty("path").GetString());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync()
{
// Arrange
JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 });
byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot);
DataContent snapshotContent = new(snapshotBytes, "application/json");
JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta);
DataContent deltaContent = new(deltaBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is StateSnapshotEvent);
Assert.Contains(outputEvents, e => e is StateDeltaEvent);
}
[Fact]
public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync()
{
// Arrange - Create complex JSON Patch with various operations
JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "add", path = "/user/email", value = "test@example.com" },
new { op = "remove", path = "/user/tempData" },
new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" },
new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" },
new { op = "copy", from = "/user/name", path = "/user/displayName" },
new { op = "test", path = "/user/version", value = 2 }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = originalDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act - Convert to ChatResponseUpdate and back to events
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
List<BaseEvent> roundTripEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
roundTripEvents.Add(evt);
}
// Assert
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(roundTripDelta);
Assert.NotNull(roundTripDelta.Delta);
JsonElement delta = roundTripDelta.Delta.Value;
Assert.Equal(6, delta.GetArrayLength());
// Verify each operation type
Assert.Equal("add", delta[0].GetProperty("op").GetString());
Assert.Equal("remove", delta[1].GetProperty("op").GetString());
Assert.Equal("replace", delta[2].GetProperty("op").GetString());
Assert.Equal("move", delta[3].GetProperty("op").GetString());
Assert.Equal("copy", delta[4].GetProperty("op").GetString());
Assert.Equal("test", delta[5].GetProperty("op").GetString());
}
#endregion State Delta Tests
}
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class A2AIntegrationTests
{
/// <summary>
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
/// </summary>
[Fact]
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication",
Version = "1.0"
};
// Map A2A with the agent card
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
await app.StartAsync();
try
{
// Get the test server client
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
var httpClient = testServer.CreateClient();
// Act - Query the agent card endpoint
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
var response = await httpClient.GetAsync(requestUri);
// Assert
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
var content = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// Verify the card has expected properties
Assert.True(root.TryGetProperty("name", out var nameProperty));
Assert.Equal("Test Agent", nameProperty.GetString());
Assert.True(root.TryGetProperty("description", out var descProperty));
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
// Verify the card has a URL property and it's not null/empty
Assert.True(root.TryGetProperty("url", out var urlProperty));
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
var url = urlProperty.GetString();
Assert.NotNull(url);
Assert.NotEmpty(url);
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent/v1/card", url);
}
finally
{
await app.StopAsync();
}
}
}
@@ -1,10 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -478,25 +476,4 @@ public sealed class EndpointRouteA2ABuilderExtensionsTests
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
}
private sealed class DummyChatClient : IChatClient
{
public void Dispose()
{
throw new NotImplementedException();
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
internal sealed class DummyChatClient : IChatClient
{
public void Dispose()
{
throw new NotImplementedException();
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
@@ -6,6 +6,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
<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" />
</ItemGroup>
@@ -0,0 +1,441 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.AGUI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
public sealed class SharedStateTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task StateSnapshot_IsReturnedAsDataContent_WithCorrectMediaTypeAsync()
{
// Arrange
var initialState = new { counter = 42, status = "active" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "update state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Should receive state snapshot as DataContent with application/json media type
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull("should receive state snapshot update");
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
dataContent.Should().NotBeNull();
// Verify the state content
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented");
receivedState.GetProperty("status").GetString().Should().Be("active");
}
[Fact]
public async Task StateSnapshot_HasCorrectAdditionalPropertiesAsync()
{
// Arrange
var initialState = new { step = 1 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
ChatResponseUpdate chatUpdate = stateUpdate!.AsChatResponseUpdate();
chatUpdate.AdditionalProperties.Should().NotBeNull();
chatUpdate.AdditionalProperties.Should().ContainKey("is_state_snapshot");
((bool)chatUpdate.AdditionalProperties!["is_state_snapshot"]!).Should().BeTrue();
}
[Fact]
public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsync()
{
// Arrange
var complexState = new
{
sessionId = "test-123",
nested = new { value = "test", count = 10 },
array = new[] { 1, 2, 3 },
tags = new[] { "tag1", "tag2" }
};
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(complexState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process complex state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("sessionId").GetString().Should().Be("test-123");
receivedState.GetProperty("nested").GetProperty("count").GetInt32().Should().Be(10);
receivedState.GetProperty("array").GetArrayLength().Should().Be(3);
receivedState.GetProperty("tags").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task StateSnapshot_CanBeUsedInSubsequentRequest_ForStateRoundTripAsync()
{
// Arrange
var initialState = new { counter = 1, sessionId = "round-trip-test" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "increment");
List<AgentRunResponseUpdate> firstRoundUpdates = [];
// Act - First round
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
firstRoundUpdates.Add(update);
}
// Extract state snapshot from first round
AgentRunResponseUpdate? firstStateUpdate = firstRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
firstStateUpdate.Should().NotBeNull();
DataContent? firstStateContent = firstStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
// Second round - use returned state
ChatMessage secondStateMessage = new(ChatRole.System, [firstStateContent!]);
ChatMessage secondUserMessage = new(ChatRole.User, "increment again");
List<AgentRunResponseUpdate> secondRoundUpdates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([secondUserMessage, secondStateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
secondRoundUpdates.Add(update);
}
// Assert - Second round should have incremented counter again
AgentRunResponseUpdate? secondStateUpdate = secondRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
secondStateUpdate.Should().NotBeNull();
DataContent? secondStateContent = secondStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray());
JsonElement secondState = JsonSerializer.Deserialize<JsonElement>(secondStateJson);
secondState.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3");
}
[Fact]
public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync()
{
// Arrange
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Should NOT have state snapshot when no state is sent
bool hasStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateSnapshot.Should().BeFalse("should not return state snapshot when no state is provided");
// Should have normal text response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task EmptyState_DoesNotTriggerStateHandlingAsync()
{
// Arrange
var emptyState = new { };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Empty state {} should not trigger state snapshot mechanism
bool hasEmptyStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasEmptyStateSnapshot.Should().BeFalse("empty state should be treated as no state");
// Should have normal response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task NonStreamingRunAsync_WithState_ReturnsStateInResponseAsync()
{
// Arrange
var initialState = new { counter = 5 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
// Act
AgentRunResponse response = await agent.RunAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Messages.Should().NotBeEmpty();
// Should have message with DataContent containing state
bool hasStateMessage = response.Messages.Any(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateMessage.Should().BeTrue("response should contain state message");
ChatMessage? stateResponseMessage = response.Messages.FirstOrDefault(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateResponseMessage.Should().NotBeNull();
DataContent? dataContent = stateResponseMessage!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(6);
}
private async Task SetupTestServerAsync(FakeStateAgent fakeAgent)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
builder.WebHost.UseTestServer();
this._app = builder.Build();
this._app.MapAGUI("/agent", fakeAgent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._client = testServer.CreateClient();
this._client.BaseAddress = new Uri("http://localhost/agent");
}
public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated in tests")]
internal sealed class FakeStateAgent : AIAgent
{
public override string? Description => "Agent for state testing";
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Check for state in ChatOptions.AdditionalProperties (set by AG-UI hosting layer)
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } &&
properties.TryGetValue("ag_ui_state", out object? stateObj) &&
stateObj is JsonElement state &&
state.ValueKind == JsonValueKind.Object)
{
// Check if state object has properties (not empty {})
bool hasProperties = false;
foreach (JsonProperty _ in state.EnumerateObject())
{
hasProperties = true;
break;
}
if (hasProperties)
{
// State is present and non-empty - modify it and return as DataContent
Dictionary<string, object?> modifiedState = [];
foreach (JsonProperty prop in state.EnumerateObject())
{
if (prop.Name == "counter" && prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32() + 1;
}
else if (prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32();
}
else if (prop.Value.ValueKind == JsonValueKind.String)
{
modifiedState[prop.Name] = prop.Value.GetString();
}
else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
{
modifiedState[prop.Name] = prop.Value;
}
}
// Return modified state as DataContent
string modifiedStateJson = JsonSerializer.Serialize(modifiedState);
byte[] modifiedStateBytes = System.Text.Encoding.UTF8.GetBytes(modifiedStateJson);
DataContent modifiedStateContent = new(modifiedStateBytes, "application/json");
yield return new AgentRunResponseUpdate
{
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
Contents = [modifiedStateContent]
};
}
}
// Always return a text response
string messageId = Guid.NewGuid().ToString("N");
yield return new AgentRunResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent("State processed")]
};
await Task.CompletedTask;
}
public override AgentThread GetNewThread() => new FakeInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
{
public FakeInMemoryAgentThread()
: base()
{
}
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{
}
}
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
}
@@ -190,6 +190,264 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
Assert.Equal("Second", capturedMessages[1].Text);
}
[Fact]
public async Task MapAGUIAgent_ProducesValidAGUIEventStream_WithRunStartAndFinishAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
JsonElement runFinished = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunFinished);
Assert.Equal("thread1", runStarted.GetProperty("threadId").GetString());
Assert.Equal("run1", runStarted.GetProperty("runId").GetString());
Assert.Equal("thread1", runFinished.GetProperty("threadId").GetString());
Assert.Equal("run1", runFinished.GetProperty("runId").GetString());
}
[Fact]
public async Task MapAGUIAgent_ProducesTextMessageEvents_InCorrectOrderAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Hello" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<string?> eventTypes = new(events.Count);
foreach (JsonElement evt in events)
{
eventTypes.Add(evt.GetProperty("type").GetString());
}
Assert.Contains(AGUIEventTypes.RunStarted, eventTypes);
Assert.Contains(AGUIEventTypes.TextMessageContent, eventTypes);
Assert.Contains(AGUIEventTypes.RunFinished, eventTypes);
int runStartIndex = eventTypes.IndexOf(AGUIEventTypes.RunStarted);
int firstContentIndex = eventTypes.IndexOf(AGUIEventTypes.TextMessageContent);
int runFinishIndex = eventTypes.LastIndexOf(AGUIEventTypes.RunFinished);
Assert.True(runStartIndex < firstContentIndex, "Run start should precede text content.");
Assert.True(firstContentIndex < runFinishIndex, "Text content should precede run finish.");
}
[Fact]
public async Task MapAGUIAgent_EmitsTextMessageContent_WithCorrectDeltaAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement textContentEvent = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent);
Assert.Equal("Test response", textContentEvent.GetProperty("delta").GetString());
}
[Fact]
public async Task MapAGUIAgent_WithCustomAgent_ProducesExpectedStreamStructureAsync()
{
// Arrange
AIAgent customAgentFactory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
return new MultiResponseAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "custom_thread",
RunId = "custom_run",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Multi" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate(customAgentFactory);
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<JsonElement> contentEvents = new();
foreach (JsonElement evt in events)
{
if (evt.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent)
{
contentEvents.Add(evt);
}
}
Assert.True(contentEvents.Count >= 3, $"Expected at least 3 text_message.content events, got {contentEvents.Count}");
List<string?> deltas = new(contentEvents.Count);
foreach (JsonElement contentEvent in contentEvents)
{
deltas.Add(contentEvent.GetProperty("delta").GetString());
}
Assert.Contains("First", deltas);
Assert.Contains(" part", deltas);
Assert.Contains(" of response", deltas);
}
[Fact]
public async Task MapAGUIAgent_ProducesCorrectThreadAndRunIds_InAllEventsAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "test_thread_123",
RunId = "test_run_456",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
Assert.Equal("test_thread_123", runStarted.GetProperty("threadId").GetString());
Assert.Equal("test_run_456", runStarted.GetProperty("runId").GetString());
}
private static List<JsonElement> ParseSseEvents(string responseContent)
{
List<JsonElement> events = [];
using StringReader reader = new(responseContent);
StringBuilder dataBuilder = new();
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("data:", StringComparison.Ordinal))
{
string payload = line.Length > 5 && line[5] == ' '
? line.Substring(6)
: line.Substring(5);
dataBuilder.Append(payload);
}
else if (line.Length == 0 && dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
dataBuilder.Clear();
}
}
if (dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
}
return events;
}
private sealed class MultiResponseAgent : AIAgent
{
public override string Id => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "First"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " part"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " of response"));
}
}
private RequestDelegate CreateRequestDelegate(
Func<IEnumerable<ChatMessage>, IEnumerable<AITool>, IEnumerable<KeyValuePair<string, string>>, JsonElement, AIAgent> factory)
{
@@ -0,0 +1,53 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "What's the weather like in San Francisco?"
}
],
"max_completion_tokens": 256,
"temperature": 0.7,
"top_p": 1,
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": [ "celsius", "fahrenheit" ],
"description": "Temperature unit"
}
},
"required": [ "location" ]
}
}
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Get the current time in a given timezone",
"parameters": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "The IANA timezone, e.g. America/Los_Angeles"
}
},
"required": [ "timezone" ]
}
}
}
]
}
@@ -0,0 +1,42 @@
{
"id": "chatcmpl-tools-test-001",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\", \"unit\": \"fahrenheit\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 85,
"completion_tokens": 32,
"total_tokens": 117,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default"
}
@@ -27,16 +27,4 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\streaming\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\response.json" />
</ItemGroup>
</Project>
@@ -456,6 +456,136 @@ public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase
Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind);
}
[Fact]
public async Task ToolsSerializationDeserializationAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("tools/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("tools/response.json");
HttpClient client = await this.CreateTestServerAsync(
"tools-agent",
"You are a helpful assistant with access to weather and time tools.",
"tool-call",
(msg) => [new FunctionCallContent("call_abc123", "get_weather", new Dictionary<string, object?>() {
{ "location", "San Francisco, CA" },
{ "unit", "fahrenheit" }
})]
);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "tools-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has tools array with proper structure
AssertJsonPropertyExists(request, "tools");
var tools = request.GetProperty("tools");
Assert.Equal(JsonValueKind.Array, tools.ValueKind);
Assert.Equal(2, tools.GetArrayLength());
// Assert - First tool (get_weather)
var weatherTool = tools[0];
AssertJsonPropertyEquals(weatherTool, "type", "function");
AssertJsonPropertyExists(weatherTool, "function");
var weatherFunction = weatherTool.GetProperty("function");
AssertJsonPropertyEquals(weatherFunction, "name", "get_weather");
AssertJsonPropertyExists(weatherFunction, "description");
AssertJsonPropertyExists(weatherFunction, "parameters");
var weatherParams = weatherFunction.GetProperty("parameters");
AssertJsonPropertyEquals(weatherParams, "type", "object");
AssertJsonPropertyExists(weatherParams, "properties");
AssertJsonPropertyExists(weatherParams, "required");
// Verify location property exists
var properties = weatherParams.GetProperty("properties");
AssertJsonPropertyExists(properties, "location");
AssertJsonPropertyExists(properties, "unit");
// Assert - Second tool (get_time)
var timeTool = tools[1];
AssertJsonPropertyEquals(timeTool, "type", "function");
var timeFunction = timeTool.GetProperty("function");
AssertJsonPropertyEquals(timeFunction, "name", "get_time");
AssertJsonPropertyExists(timeFunction, "description");
AssertJsonPropertyExists(timeFunction, "parameters");
// Assert - Response structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "chat.completion");
AssertJsonPropertyExists(response, "created");
AssertJsonPropertyExists(response, "model");
// Assert - Response has tool_calls in choices
var choices = response.GetProperty("choices");
Assert.Equal(JsonValueKind.Array, choices.ValueKind);
Assert.True(choices.GetArrayLength() > 0);
var choice = choices[0];
AssertJsonPropertyExists(choice, "finish_reason");
AssertJsonPropertyEquals(choice, "finish_reason", anyOfValues: ["tool_calls", "stop"]);
AssertJsonPropertyExists(choice, "message");
var message = choice.GetProperty("message");
AssertJsonPropertyEquals(message, "role", "assistant");
AssertJsonPropertyExists(message, "tool_calls");
// Assert - Tool calls array structure
var toolCalls = message.GetProperty("tool_calls");
Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind);
Assert.True(toolCalls.GetArrayLength() > 0);
var toolCall = toolCalls[0];
AssertJsonPropertyExists(toolCall, "id");
AssertJsonPropertyEquals(toolCall, "type", "function");
AssertJsonPropertyExists(toolCall, "function");
var callFunction = toolCall.GetProperty("function");
AssertJsonPropertyEquals(callFunction, "name", "get_weather");
AssertJsonPropertyExists(callFunction, "arguments");
// Assert - Tool call arguments are valid JSON
string arguments = callFunction.GetProperty("arguments").GetString()!;
using var argsDoc = JsonDocument.Parse(arguments);
var argsRoot = argsDoc.RootElement;
AssertJsonPropertyExists(argsRoot, "location");
AssertJsonPropertyEquals(argsRoot, "location", "San Francisco, CA");
AssertJsonPropertyEquals(argsRoot, "unit", "fahrenheit");
// Assert - Message content is null when tool_calls present
if (message.TryGetProperty("content", out var contentProp))
{
Assert.Equal(JsonValueKind.Null, contentProp.ValueKind);
}
// Assert - Usage statistics
AssertJsonPropertyExists(response, "usage");
var usage = response.GetProperty("usage");
AssertJsonPropertyExists(usage, "prompt_tokens");
AssertJsonPropertyExists(usage, "completion_tokens");
AssertJsonPropertyExists(usage, "total_tokens");
var promptTokens = usage.GetProperty("prompt_tokens").GetInt32();
var completionTokens = usage.GetProperty("completion_tokens").GetInt32();
var totalTokens = usage.GetProperty("total_tokens").GetInt32();
Assert.True(promptTokens > 0);
Assert.True(completionTokens > 0);
Assert.Equal(promptTokens + completionTokens, totalTokens);
// Assert - Service tier
AssertJsonPropertyExists(response, "service_tier");
var serviceTier = response.GetProperty("service_tier").GetString();
Assert.NotNull(serviceTier);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
@@ -45,7 +45,7 @@ public sealed class OpenAIHttpApiIntegrationTests : IAsyncDisposable
// Act - Create response (non-streaming, non-background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = false
@@ -122,7 +122,7 @@ public sealed class OpenAIHttpApiIntegrationTests : IAsyncDisposable
// Act - Create response (streaming, non-background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = true
@@ -196,7 +196,7 @@ public sealed class OpenAIHttpApiIntegrationTests : IAsyncDisposable
// Act - Create response (non-streaming, background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = false,
@@ -168,23 +168,23 @@ public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDispo
}
/// <summary>
/// Verifies that agent resolution using the model property works correctly.
/// Verifies that agent resolution using the metadata.entity_id property works correctly.
/// </summary>
[Fact]
public async Task CreateResponse_WithModelProperty_ResolvesCorrectAgentAsync()
public async Task CreateResponse_WithMetadataEntityId_ResolvesCorrectAgentAsync()
{
// Arrange
const string AgentName = "model-agent";
const string AgentName = "metadata-agent";
const string Instructions = "You are a helpful assistant.";
const string ExpectedResponse = "Response via model property";
const string ExpectedResponse = "Response via metadata.entity_id";
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
(AgentName, Instructions, ExpectedResponse));
// Act - Use raw HTTP request to control the model property
// Act - Use raw HTTP request with metadata.entity_id
using StringContent requestContent = new(JsonSerializer.Serialize(new
{
model = AgentName,
metadata = new { entity_id = AgentName },
input = new[]
{
new { type = "message", role = "user", content = "Test message" }
@@ -235,7 +235,7 @@ public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDispo
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
// Assert
Assert.Equal(System.Net.HttpStatusCode.NotFound, httpResponse.StatusCode);
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
string responseJson = await httpResponse.Content.ReadAsStringAsync();
Assert.Contains("non-existent-agent", responseJson);
@@ -268,7 +268,6 @@ public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDispo
string responseJson = await httpResponse.Content.ReadAsStringAsync();
Assert.Contains("agent.name", responseJson, StringComparison.OrdinalIgnoreCase);
Assert.Contains("model", responseJson, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -344,6 +344,20 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase
Assert.NotNull(request.Input);
}
[Fact]
public void Deserialize_InvalidInputObject_ThrowsHelpfulException()
{
// Arrange
const string Json = "{\"model\":\"gpt-4o-mini\",\"input\":{\"input\":\"testing!\"},\"stream\":true}";
// Act & Assert
var exception = Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize(Json, OpenAIHostingJsonContext.Default.CreateResponse));
Assert.Contains("ResponseInput must be either a string or an array of messages", exception.Message);
Assert.Contains("Objects are not supported", exception.Message);
}
[Fact]
public void Deserialize_AllRequests_CanBeDeserialized()
{
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetMultipleVariablesExecutor"/>.
/// </summary>
public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task SetMultipleVariablesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesAsync),
assignments: [
new AssignmentCase("Variable1", new NumberDataValue(42), FormulaValue.New(42)),
new AssignmentCase("Variable2", new StringDataValue("Test"), FormulaValue.New("Test")),
new AssignmentCase("Variable3", new BooleanDataValue(true), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithExpressionsAsync()
{
// Arrange
this.State.Set("SourceNumber", FormulaValue.New(10));
this.State.Set("SourceText", FormulaValue.New("Hello"));
this.State.Bind();
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithExpressionsAsync),
assignments: [
new AssignmentCase("CalcVariable", ValueExpression.Expression("Local.SourceNumber * 2"), FormulaValue.New(20)),
new AssignmentCase("ConcatVariable", ValueExpression.Expression(@"Concatenate(Local.SourceText, "" World"")"), FormulaValue.New("Hello World")),
new AssignmentCase("BoolVariable", ValueExpression.Expression("Local.SourceNumber > 5"), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithVariableReferencesAsync()
{
// Arrange
this.State.Set("Source1", FormulaValue.New(123));
this.State.Set("Source2", FormulaValue.New("Reference"));
this.State.Bind();
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithVariableReferencesAsync),
assignments: [
new AssignmentCase("Target1", ValueExpression.Variable(PropertyPath.TopicVariable("Source1")), FormulaValue.New(123)),
new AssignmentCase("Target2", ValueExpression.Variable(PropertyPath.TopicVariable("Source2")), FormulaValue.New("Reference"))
]);
}
[Fact]
public async Task SetMultipleVariablesWithNullValuesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithNullValuesAsync),
assignments: [
new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()),
new AssignmentCase("NormalVar", new StringDataValue("NotNull"), FormulaValue.New("NotNull")),
new AssignmentCase("NullVar2", null, FormulaValue.NewBlank())
]);
}
[Fact]
public async Task SetMultipleVariablesUpdateExistingAsync()
{
// Arrange
this.State.Set("ExistingVar1", FormulaValue.New(999));
this.State.Set("ExistingVar2", FormulaValue.New("OldValue"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesUpdateExistingAsync),
assignments: [
new AssignmentCase("ExistingVar1", new NumberDataValue(111), FormulaValue.New(111)),
new AssignmentCase("ExistingVar2", new StringDataValue("NewValue"), FormulaValue.New("NewValue")),
new AssignmentCase("NewVar", new BooleanDataValue(false), FormulaValue.New(false))
]);
}
[Fact]
public async Task SetMultipleVariablesEmptyAssignmentsAsync()
{
// Arrange
SetMultipleVariables model = this.CreateModel(nameof(SetMultipleVariablesEmptyAssignmentsAsync), []);
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() =>
{
// Empty variables assignment should fail RequiredProperties validation.
_ = new SetMultipleVariablesExecutor(model, this.State);
});
}
private async Task ExecuteTestAsync(string displayName, AssignmentCase[] assignments)
{
// Arrange
SetMultipleVariables model = this.CreateModel(displayName, assignments);
// Act
SetMultipleVariablesExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
foreach (AssignmentCase assignment in assignments)
{
this.VerifyState(assignment.VariableName, assignment.ExpectedValue);
}
}
private SetMultipleVariables CreateModel(string displayName, AssignmentCase[] assignments)
{
SetMultipleVariables.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
};
foreach (AssignmentCase assignment in assignments)
{
ValueExpression.Builder? valueExpressionBuilder = assignment.ValueExpression switch
{
null => null,
DataValue dataValue => new ValueExpression.Builder(ValueExpression.Literal(dataValue)),
ValueExpression valueExpression => new ValueExpression.Builder(valueExpression),
_ => throw new System.ArgumentException($"Unsupported value type: {assignment.ValueExpression?.GetType().Name}")
};
actionBuilder.Assignments.Add(new VariableAssignment.Builder()
{
Variable = PropertyPath.Create(FormatVariablePath(assignment.VariableName)),
Value = valueExpressionBuilder,
});
}
return AssignParent<SetMultipleVariables>(actionBuilder);
}
private sealed record AssignmentCase(string VariableName, object? ValueExpression, FormulaValue ExpectedValue);
}