mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-foundry-agents
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 +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
-1
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.|
|
||||
|
||||
-28
@@ -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>
|
||||
-354
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-33
@@ -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();
|
||||
|
||||
+13
@@ -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();
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+15
-10
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+45
-38
@@ -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>
|
||||
|
||||
+18
-20
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+408
@@ -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
-24
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+441
@@ -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;
|
||||
}
|
||||
+258
@@ -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)
|
||||
{
|
||||
|
||||
+53
@@ -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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+42
@@ -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"
|
||||
}
|
||||
-12
@@ -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>
|
||||
|
||||
+130
@@ -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>
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+7
-8
@@ -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>
|
||||
|
||||
+14
@@ -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()
|
||||
{
|
||||
|
||||
+154
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user