diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 5abfe2a879d..8c9fe22ffce 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -8,11 +8,11 @@ name: dotnet-build-and-test on: workflow_dispatch: pull_request: - branches: ["main"] + branches: ["main", "feature*"] merge_group: - branches: ["main"] + branches: ["main", "feature*"] push: - branches: ["main"] + branches: ["main", "feature*"] schedule: - cron: "0 0 * * *" # Run at midnight UTC daily diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index 9ea5b8022d6..81a506f2777 100644 --- a/.github/workflows/python-test-coverage-report.yml +++ b/.github/workflows/python-test-coverage-report.yml @@ -39,7 +39,7 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - name: Pytest coverage comment id: coverageComment - uses: MishaKav/pytest-coverage-comment@v1.1.57 + uses: MishaKav/pytest-coverage-comment@v1.1.59 with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} issue-number: ${{ env.PR_NUMBER }} diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index d110ec44269..bf3ff94eba3 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -44,7 +44,7 @@ - + @@ -68,15 +68,15 @@ - - - - - - - - + + + + + + + + @@ -86,7 +86,7 @@ - + @@ -104,8 +104,8 @@ - - + + @@ -135,7 +135,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 3e845c75d90..71c79efc443 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -20,6 +20,7 @@ + @@ -47,8 +48,7 @@ - - + @@ -58,20 +58,22 @@ - + - - - - + + + + + + @@ -80,7 +82,8 @@ - + + @@ -155,10 +158,10 @@ - - - - + + + + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 0e92f8ef067..cbcc78bb9dd 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251107.1 - $(VersionPrefix)-preview.251107.1 - 1.0.0-preview.251107.1 + $(VersionPrefix)-$(VersionSuffix).251110.2 + $(VersionPrefix)-preview.251110.2 + 1.0.0-preview.251110.2 Debug;Release;Publish true diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj new file mode 100644 index 00000000000..0513374a935 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + enable + enable + b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21 + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs new file mode 100644 index 00000000000..af86dc25989 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs @@ -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; diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs new file mode 100644 index 00000000000..5145c5559de --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -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 + }; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs new file mode 100644 index 00000000000..4be57405aec --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Ingredient.cs @@ -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; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs new file mode 100644 index 00000000000..57cc409c589 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Program.cs @@ -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>(); +app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions)); + +await app.RunAsync(); + +public partial class Program { } diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Properties/launchSettings.json b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Properties/launchSettings.json new file mode 100644 index 00000000000..d1c2dbfa920 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AGUIDojoServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5018" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs new file mode 100644 index 00000000000..9af4f6eae9b --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/Recipe.cs @@ -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 SpecialPreferences { get; set; } = []; + + [JsonPropertyName("ingredients")] + public List Ingredients { get; set; } = []; + + [JsonPropertyName("instructions")] + public List Instructions { get; set; } = []; +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs new file mode 100644 index 00000000000..0e9b2f2fff6 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/RecipeResponse.cs @@ -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(); +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs new file mode 100644 index 00000000000..ea2f1d319f2 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedStateAgent.cs @@ -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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable 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( + 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(); + 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; + } + } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs new file mode 100644 index 00000000000..e5b4811739c --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/WeatherInfo.cs @@ -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; } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.Development.json b/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.Development.json new file mode 100644 index 00000000000..3e805edef80 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" + } + } +} diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.json b/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.json new file mode 100644 index 00000000000..bb20fb69dd7 --- /dev/null +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index cb1a7e3cd94..46af2a5b198 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -107,8 +107,8 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te 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.", diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md b/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md index 3542bf5b30c..8f675a20d1e 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/README.md @@ -142,11 +142,11 @@ You: Besides the Aspire Dashboard and the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly: ### Agent Overview dashboard -Grafana Dashboard Gallery link: +Open dashboard in Azure portal: ![Agent Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-agent.gif) ### Workflow Overview dashboard -Grafana Dashboard Gallery link: +Open dashboard in Azure portal: ![Workflow Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-workflow.gif) ## Key Features Demonstrated diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Program.cs rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Agent_Step03.1_UsingFunctionTools.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Agent_Step03.1_UsingFunctionTools.csproj rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs rename to dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/README.md new file mode 100644 index 00000000000..903fcf1b78a --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/README.md @@ -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.| diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj similarity index 100% rename from dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj rename to dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs similarity index 97% rename from dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs rename to dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs index 4e8fbf0bde0..89ced52b69e 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs @@ -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. diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md similarity index 100% rename from dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/README.md rename to dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/README.md diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Agent_Step13_Memory.csproj b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Agent_Step13_Memory.csproj rename to dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs similarity index 94% rename from dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs rename to dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs index c56be11fc51..38bc2e09f39 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs @@ -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; diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/README.md b/dotnet/samples/GettingStarted/AgentWithRAG/README.md index f45c2c25401..bf2a8f9b111 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/README.md +++ b/dotnet/samples/GettingStarted/AgentWithRAG/README.md @@ -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.| diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Agent_Step03.2_UsingFunctionTools_FromOpenAPI.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Agent_Step03.2_UsingFunctionTools_FromOpenAPI.csproj deleted file mode 100644 index e2edbb2f8df..00000000000 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Agent_Step03.2_UsingFunctionTools_FromOpenAPI.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - Exe - net9.0 - - enable - enable - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json b/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json deleted file mode 100644 index 84715914da6..00000000000 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/OpenAPISpec.json +++ /dev/null @@ -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": "; rel=\"next\", ; 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" - } - } - } - } -} \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs deleted file mode 100644 index e61c9f845a3..00000000000 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Program.cs +++ /dev/null @@ -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 tools = plugin.Select(x => x.WithKernel(kernel)).Cast().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.")); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj rename to dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Program.cs rename to dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj rename to dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Program.cs rename to dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md similarity index 100% rename from dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/README.md rename to dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/README.md diff --git a/dotnet/samples/GettingStarted/Agents/README.md b/dotnet/samples/GettingStarted/Agents/README.md index 562b6b2500b..b93e9ceb723 100644 --- a/dotnet/samples/GettingStarted/Agents/README.md +++ b/dotnet/samples/GettingStarted/Agents/README.md @@ -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 diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md index e7249ac33dc..4fdf0a3d7f0 100644 --- a/dotnet/samples/GettingStarted/README.md +++ b/dotnet/samples/GettingStarted/README.md @@ -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| diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 653ebdf4c2d..e418ca71312 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -16,7 +16,7 @@ internal static class WorkflowFactory internal static Workflow BuildWorkflow(IChatClient chatClient) { // Create executors - var startExecutor = new ConcurrentStartExecutor(); + var startExecutor = new ChatForwardingExecutor("Start"); var aggregationExecutor = new ConcurrentAggregationExecutor(); AIAgent frenchAgent = GetLanguageAgent("French", chatClient); AIAgent englishAgent = GetLanguageAgent("English", chatClient); @@ -38,33 +38,11 @@ internal static Workflow BuildWorkflow(IChatClient chatClient) private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient) => new(chatClient, instructions: $"You're a helpful assistant who always responds in {targetLanguage}.", name: $"{targetLanguage}Agent"); - /// - /// Executor that starts the concurrent processing by sending messages to the agents. - /// - private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor") - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder - .AddHandler>(this.RouteMessages) - .AddHandler(this.RouteTurnTokenAsync); - } - - private ValueTask RouteMessages(List messages, IWorkflowContext context, CancellationToken cancellationToken) - { - return context.SendMessageAsync(messages, cancellationToken: cancellationToken); - } - - private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken) - { - return context.SendMessageAsync(token, cancellationToken: cancellationToken); - } - } - /// /// Executor that aggregates the results from the concurrent agents. /// - private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor") + private sealed class ConcurrentAggregationExecutor() : + Executor>("ConcurrentAggregationExecutor"), IResettableExecutor { private readonly List _messages = []; @@ -85,5 +63,12 @@ public override async ValueTask HandleAsync(List message, IWorkflow await context.YieldOutputAsync(formattedMessages, cancellationToken); } } + + /// + public ValueTask ResetAsync() + { + this._messages.Clear(); + return default; + } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs index 859b74b194b..54c77d40778 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs @@ -42,7 +42,7 @@ private void Execute() Console.WriteLine(code); } - private const string DefaultWorkflow = "HelloWorld.yaml"; + private const string DefaultWorkflow = "Marketing.yaml"; private string WorkflowFile { get; } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md index 03023ea8472..d2bbaa14a6a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md @@ -92,11 +92,11 @@ The repository has example workflows available in the root [`/workflow-samples`] 2. Run the demo referencing a sample workflow by name: ```sh - dotnet run HelloWorld + dotnet run Marketing ``` 3. Run the demo with a path to any workflow file: ```sh - dotnet run c:/myworkflows/HelloWorld.yaml + dotnet run c:/myworkflows/Marketing.yaml ``` diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj similarity index 100% rename from dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj rename to dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs similarity index 100% rename from dotnet/samples/Catalog/AgentWithTextSearchRag/Program.cs rename to dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs diff --git a/dotnet/samples/Catalog/AgentWithTextSearchRag/README.md b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/README.md similarity index 100% rename from dotnet/samples/Catalog/AgentWithTextSearchRag/README.md rename to dotnet/samples/HostedAgents/AgentWithTextSearchRag/README.md diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj similarity index 100% rename from dotnet/samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj rename to dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/Program.cs b/dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs similarity index 100% rename from dotnet/samples/Catalog/AgentsInWorkflows/Program.cs rename to dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs diff --git a/dotnet/samples/Catalog/AgentsInWorkflows/README.md b/dotnet/samples/HostedAgents/AgentsInWorkflows/README.md similarity index 100% rename from dotnet/samples/Catalog/AgentsInWorkflows/README.md rename to dotnet/samples/HostedAgents/AgentsInWorkflows/README.md diff --git a/dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj b/dotnet/samples/HostedAgents/DeepResearchAgent/DeepResearchAgent.csproj similarity index 100% rename from dotnet/samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj rename to dotnet/samples/HostedAgents/DeepResearchAgent/DeepResearchAgent.csproj diff --git a/dotnet/samples/Catalog/DeepResearchAgent/Program.cs b/dotnet/samples/HostedAgents/DeepResearchAgent/Program.cs similarity index 100% rename from dotnet/samples/Catalog/DeepResearchAgent/Program.cs rename to dotnet/samples/HostedAgents/DeepResearchAgent/Program.cs diff --git a/dotnet/samples/Catalog/DeepResearchAgent/README.md b/dotnet/samples/HostedAgents/DeepResearchAgent/README.md similarity index 100% rename from dotnet/samples/Catalog/DeepResearchAgent/README.md rename to dotnet/samples/HostedAgents/DeepResearchAgent/README.md diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs index 11894eb4880..a168e2eab64 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs @@ -4,6 +4,7 @@ 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 @@ private static ChatResponseUpdate CopyResponseUpdate(ChatResponseUpdate source) 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 async IAsyncEnumerable GetStreamingResponseAsync( 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 async IAsyncEnumerable GetStreamingResponseAsync( ThreadId = threadId, RunId = runId, Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions), + State = state, }; // Add tools if provided @@ -300,6 +307,51 @@ public async IAsyncEnumerable GetStreamingResponseAsync( 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 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 void Dispose() } } - private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent + private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent { public FunctionCallContent FunctionCallContent { get; } = functionCall; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs index 731d8a8f428..1b8958cdf0f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs @@ -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"; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs index 7c4338f0c92..0b571c4ff19 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs @@ -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))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] @@ -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 { } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs index af2414d7f04..eca2131f23d 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs @@ -46,6 +46,7 @@ public override BaseEvent Read( 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 @@ public override void Write( 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}"); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index 9b865afabe8..46184a6588f 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -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 AsChatResponseUpdatesAsync( this IAsyncEnumerable events, JsonSerializerOptions jsonSerializerOptions, @@ -70,11 +74,73 @@ public static async IAsyncEnumerable AsChatResponseUpdatesAs 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 @@ private static ChatResponseUpdate ValidateAndEmitRunFinished(string? conversatio }; } - private class ToolCallBuilder + private sealed class ToolCallBuilder { private string? _conversationId; private string? _responseId; @@ -348,6 +414,55 @@ chatResponse.Contents[0] is TextContent && 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 + }; + } + } } } } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs new file mode 100644 index 00000000000..98d3b168b34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs @@ -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; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs new file mode 100644 index 00000000000..dc77e4ba466 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs @@ -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; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index c6a64915cf2..72629792079 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -32,6 +33,7 @@ public AgentRunOptions(AgentRunOptions options) _ = Throw.IfNull(options); this.ContinuationToken = options.ContinuationToken; this.AllowBackgroundResponses = options.AllowBackgroundResponses; + this.AdditionalProperties = options.AdditionalProperties?.Clone(); } /// @@ -74,4 +76,18 @@ public AgentRunOptions(AgentRunOptions options) /// /// public bool? AllowBackgroundResponses { get; set; } + + /// + /// Gets or sets additional properties associated with these options. + /// + /// + /// An containing custom properties, + /// or if no additional properties are present. + /// + /// + /// Additional properties provide a way to include custom metadata or provider-specific + /// information that doesn't fit into the standard options schema. This is useful for + /// preserving implementation-specific details or extending the options with custom data. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs index bbbf332f8b2..8d5159cab7b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs @@ -32,6 +32,7 @@ public static IEndpointConventionBuilder MapDevUI( { var group = endpoints.MapGroup(""); group.MapDevUI(pattern: "/devui"); + group.MapMeta(); group.MapEntities(); return group; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs index fc8bbe3864c..3acc8d48d3a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs @@ -15,10 +15,12 @@ namespace Microsoft.Agents.AI.DevUI.Entities; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(EntityInfo))] [JsonSerializable(typeof(DiscoveryResponse))] +[JsonSerializable(typeof(MetaResponse))] [JsonSerializable(typeof(EnvVarRequirement))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(JsonElement))] [ExcludeFromCodeCoverage] internal sealed partial class EntitiesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs new file mode 100644 index 00000000000..6e1260cdc74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/MetaResponse.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DevUI.Entities; + +/// +/// Server metadata response for the /meta endpoint. +/// Provides information about the DevUI server configuration, capabilities, and requirements. +/// +/// +/// This response is used by the frontend to: +/// - Determine the UI mode (developer vs user interface) +/// - Check server capabilities (tracing, OpenAI proxy support) +/// - Verify authentication requirements +/// - Display framework and version information +/// +internal sealed record MetaResponse +{ + /// + /// Gets the UI interface mode. + /// "developer" shows debug tools and advanced features, "user" shows a simplified interface. + /// + [JsonPropertyName("ui_mode")] + public string UiMode { get; init; } = "developer"; + + /// + /// Gets the DevUI version string. + /// + [JsonPropertyName("version")] + public string Version { get; init; } = "0.1.0"; + + /// + /// Gets the backend framework identifier. + /// Always "agent_framework" for Agent Framework implementations. + /// + [JsonPropertyName("framework")] + public string Framework { get; init; } = "agent_framework"; + + /// + /// Gets the backend runtime/language. + /// "dotnet" for .NET implementations, "python" for Python implementations. + /// Used by frontend for deployment guides and feature availability. + /// + [JsonPropertyName("runtime")] + public string Runtime { get; init; } = "dotnet"; + + /// + /// Gets the server capabilities dictionary. + /// Key-value pairs indicating which optional features are enabled. + /// + /// + /// Standard capability keys: + /// - "tracing": Whether trace events are emitted for debugging + /// - "openai_proxy": Whether the server can proxy requests to OpenAI + /// + [JsonPropertyName("capabilities")] + public Dictionary Capabilities { get; init; } = new(); + + /// + /// Gets a value indicating whether Bearer token authentication is required for API access. + /// When true, clients must include "Authorization: Bearer {token}" header in requests. + /// + [JsonPropertyName("auth_required")] + public bool AuthRequired { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index 716dab85420..eb41fe90b89 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI.DevUI.Entities; using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; namespace Microsoft.Agents.AI.DevUI; @@ -56,79 +58,19 @@ private static async Task ListEntitiesAsync( { var entities = new List(); - // Discover agents from the agent catalog - if (agentCatalog is not null) + // Discover agents + await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) { - await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) - { - if (agent.GetType().Name == "WorkflowHostAgent") - { - // HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows, - // and workflows are handled below. - continue; - } - - entities.Add(new EntityInfo( - Id: agent.Name ?? agent.Id, - Type: "agent", - Name: agent.Name ?? agent.Id, - Description: agent.Description, - Framework: "agent-framework", - Tools: null, - Metadata: [] - ) - { - Source = "in_memory" - }); - } + entities.Add(agentInfo); } - // Discover workflows from the workflow catalog - if (workflowCatalog is not null) + // Discover workflows + await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) { - await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false)) - { - // Extract executor IDs from the workflow structure - var executorIds = new HashSet { workflow.StartExecutorId }; - var reflectedEdges = workflow.ReflectEdges(); - foreach (var (sourceId, edgeSet) in reflectedEdges) - { - executorIds.Add(sourceId); - foreach (var edge in edgeSet) - { - foreach (var sinkId in edge.Connection.SinkIds) - { - executorIds.Add(sinkId); - } - } - } - - // Create a default input schema (string type) - var defaultInputSchema = new Dictionary - { - ["type"] = "string" - }; - - entities.Add(new EntityInfo( - Id: workflow.Name ?? workflow.StartExecutorId, - Type: "workflow", - Name: workflow.Name ?? workflow.StartExecutorId, - Description: workflow.Description, - Framework: "agent-framework", - Tools: [.. executorIds], - Metadata: [] - ) - { - Source = "in_memory", - WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()), - InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema), - InputTypeName = "string", - StartExecutorId = workflow.StartExecutorId - }); - } + entities.Add(workflowInfo); } - return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse); + return Results.Json(new DiscoveryResponse([.. entities]), EntitiesJsonContext.Default.DiscoveryResponse); } catch (Exception ex) { @@ -141,93 +83,26 @@ private static async Task ListEntitiesAsync( private static async Task GetEntityInfoAsync( string entityId, + string? type, AgentCatalog? agentCatalog, WorkflowCatalog? workflowCatalog, CancellationToken cancellationToken) { try { - // Try to find the entity among discovered agents - if (agentCatalog is not null) + if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase)) { - await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false)) { - if (agent.GetType().Name == "WorkflowHostAgent") - { - // HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows, - // and workflows are handled below. - continue; - } - - if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) || - string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase)) - { - var entityInfo = new EntityInfo( - Id: agent.Name ?? agent.Id, - Type: "agent", - Name: agent.Name ?? agent.Id, - Description: agent.Description, - Framework: "agent-framework", - Tools: null, - Metadata: [] - ) - { - Source = "in_memory" - }; - - return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo); - } + return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo); } } - // Try to find the entity among discovered workflows - if (workflowCatalog is not null) + if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase)) { - await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false)) { - var workflowId = workflow.Name ?? workflow.StartExecutorId; - if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase)) - { - // Extract executor IDs from the workflow structure - var executorIds = new HashSet { workflow.StartExecutorId }; - var reflectedEdges = workflow.ReflectEdges(); - foreach (var (sourceId, edgeSet) in reflectedEdges) - { - executorIds.Add(sourceId); - foreach (var edge in edgeSet) - { - foreach (var sinkId in edge.Connection.SinkIds) - { - executorIds.Add(sinkId); - } - } - } - - // Create a default input schema (string type) - var defaultInputSchema = new Dictionary - { - ["type"] = "string" - }; - - var entityInfo = new EntityInfo( - Id: workflowId, - Type: "workflow", - Name: workflow.Name ?? workflow.StartExecutorId, - Description: workflow.Description, - Framework: "agent-framework", - Tools: [.. executorIds], - Metadata: [] - ) - { - Source = "in_memory", - WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()), - InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema), - InputTypeName = "Input", - StartExecutorId = workflow.StartExecutorId - }; - - return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo); - } + return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo); } } @@ -241,4 +116,123 @@ private static async Task GetEntityInfoAsync( title: "Error getting entity info"); } } + + private static async IAsyncEnumerable DiscoverAgentsAsync( + AgentCatalog? agentCatalog, + string? entityIdFilter, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (agentCatalog is null) + { + yield break; + } + + await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) + { + // If filtering by entity ID, skip non-matching agents + if (entityIdFilter is not null && + !string.Equals(agent.Name, entityIdFilter, StringComparison.OrdinalIgnoreCase) && + !string.Equals(agent.Id, entityIdFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + yield return CreateAgentEntityInfo(agent); + + // If we found the entity we're looking for, we're done + if (entityIdFilter is not null) + { + yield break; + } + } + } + + private static async IAsyncEnumerable DiscoverWorkflowsAsync( + WorkflowCatalog? workflowCatalog, + string? entityIdFilter, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (workflowCatalog is null) + { + yield break; + } + + await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false)) + { + var workflowId = workflow.Name ?? workflow.StartExecutorId; + + // If filtering by entity ID, skip non-matching workflows + if (entityIdFilter is not null && !string.Equals(workflowId, entityIdFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + yield return CreateWorkflowEntityInfo(workflow); + + // If we found the entity we're looking for, we're done + if (entityIdFilter is not null) + { + yield break; + } + } + } + + private static EntityInfo CreateAgentEntityInfo(AIAgent agent) + { + var entityId = agent.Name ?? agent.Id; + return new EntityInfo( + Id: entityId, + Type: "agent", + Name: entityId, + Description: agent.Description, + Framework: "agent-framework", + Tools: null, + Metadata: [] + ) + { + Source = "in_memory" + }; + } + + private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow) + { + // Extract executor IDs from the workflow structure + var executorIds = new HashSet { workflow.StartExecutorId }; + var reflectedEdges = workflow.ReflectEdges(); + foreach (var (sourceId, edgeSet) in reflectedEdges) + { + executorIds.Add(sourceId); + foreach (var edge in edgeSet) + { + foreach (var sinkId in edge.Connection.SinkIds) + { + executorIds.Add(sinkId); + } + } + } + + // Create a default input schema (string type) + var defaultInputSchema = new Dictionary + { + ["type"] = "string" + }; + + var workflowId = workflow.Name ?? workflow.StartExecutorId; + return new EntityInfo( + Id: workflowId, + Type: "workflow", + Name: workflowId, + Description: workflow.Description, + Framework: "agent-framework", + Tools: [.. executorIds], + Metadata: [] + ) + { + Source = "in_memory", + WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()), + InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema), + InputTypeName = "string", + StartExecutorId = workflow.StartExecutorId + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs new file mode 100644 index 00000000000..4a3cfbb8f08 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DevUI.Entities; + +namespace Microsoft.Agents.AI.DevUI; + +/// +/// Provides extension methods for mapping the server metadata endpoint to an . +/// +internal static class MetaApiExtensions +{ + /// + /// Maps the HTTP API endpoint for retrieving server metadata. + /// + /// The to add the route to. + /// The for method chaining. + /// + /// This extension method registers the following endpoint: + /// + /// GET /meta - Retrieve server metadata including UI mode, version, capabilities, and auth requirements + /// + /// The endpoint is compatible with the Python DevUI frontend and provides essential + /// configuration information needed for proper frontend initialization. + /// + public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/meta", GetMeta) + .WithName("GetMeta") + .WithSummary("Get server metadata and configuration") + .WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.") + .Produces(StatusCodes.Status200OK, contentType: "application/json"); + } + + private static IResult GetMeta() + { + // TODO: Consider making these configurable via IOptions + // For now, using sensible defaults that match Python DevUI behavior + + var meta = new MetaResponse + { + UiMode = "developer", // Could be made configurable to support "user" mode + Version = "0.1.0", // TODO: Extract from assembly version attribute + Framework = "agent_framework", + Runtime = "dotnet", // .NET runtime for deployment guides + Capabilities = new Dictionary + { + // Tracing capability - will be enabled when trace event support is added + ["tracing"] = false, + + // OpenAI proxy capability - not currently supported in .NET DevUI + ["openai_proxy"] = false, + + // Deployment capability - not currently supported in .NET DevUI + ["deployment"] = false + }, + AuthRequired = false // Could be made configurable based on authentication middleware + }; + + return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 43376d8fb2c..c54af66bb8e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -83,7 +83,16 @@ public static ITaskManager MapA2A( { // 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); }; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 6e356f531d9..e20d1ab4485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -44,22 +44,27 @@ public static IEndpointConventionBuilder MapAGUI( var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; var messages = input.Messages.AsChatMessages(jsonSerializerOptions); - var agent = aiAgent; + var clientTools = input.Tools?.AsAITools().ToList(); - ChatClientAgentRunOptions? runOptions = null; - List? 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(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) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs index 470f7d15b0f..87b0637b9b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs @@ -18,7 +18,7 @@ internal abstract record Tool /// /// The type of the tool. /// - [JsonPropertyName("type")] + [JsonIgnore] public abstract string Type { get; } } @@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool /// /// The type of the tool. Always "function". /// - [JsonPropertyName("type")] + [JsonIgnore] public override string Type => "function"; /// @@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool /// /// The type of the tool. Always "custom". /// - [JsonPropertyName("type")] + [JsonIgnore] public override string Type => "custom"; /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs index 49ceef622a4..f77143c5832 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs @@ -109,6 +109,7 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(MCPApprovalRequestItemResource))] [JsonSerializable(typeof(MCPApprovalResponseItemResource))] [JsonSerializable(typeof(MCPCallItemResource))] +[JsonSerializable(typeof(ExecutorActionItemResource))] [JsonSerializable(typeof(List))] // ItemParam types [JsonSerializable(typeof(ItemParam))] diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs index d8f00735f96..628b80b340a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseUpdateExtensions.cs @@ -45,6 +45,9 @@ public static async IAsyncEnumerable ToStreamingResponse var updateEnumerator = updates.GetAsyncEnumerator(cancellationToken); await using var _ = updateEnumerator.ConfigureAwait(false); + // Track active item IDs by executor ID to pair invoked/completed/failed events + Dictionary executorItemIds = []; + AgentRunResponseUpdate? previousUpdate = null; StreamingEventGenerator? generator = null; while (await updateEnumerator.MoveNextAsync().ConfigureAwait(false)) @@ -55,7 +58,92 @@ public static async IAsyncEnumerable ToStreamingResponse // Special-case for agent framework workflow events. if (update.RawRepresentation is WorkflowEvent workflowEvent) { - yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex); + // Convert executor events to standard OpenAI output_item events + if (workflowEvent is ExecutorInvokedEvent invokedEvent) + { + var itemId = IdGenerator.NewId(prefix: "item"); + // Store the item ID for this executor so we can reuse it for completion/failure + executorItemIds[invokedEvent.ExecutorId] = itemId; + + var item = new ExecutorActionItemResource + { + Id = itemId, + ExecutorId = invokedEvent.ExecutorId, + Status = "in_progress", + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + yield return new StreamingOutputItemAdded + { + SequenceNumber = seq.Increment(), + OutputIndex = outputIndex, + Item = item + }; + } + else if (workflowEvent is ExecutorCompletedEvent completedEvent) + { + // Reuse the item ID from the invoked event, or generate a new one if not found + var itemId = executorItemIds.TryGetValue(completedEvent.ExecutorId, out var existingId) + ? existingId + : IdGenerator.NewId(prefix: "item"); + + // Remove from tracking as this executor run is now complete + executorItemIds.Remove(completedEvent.ExecutorId); + JsonElement? resultData = null; + if (completedEvent.Data != null && JsonSerializer.IsReflectionEnabledByDefault) + { + resultData = JsonSerializer.SerializeToElement( + completedEvent.Data, + OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + + var item = new ExecutorActionItemResource + { + Id = itemId, + ExecutorId = completedEvent.ExecutorId, + Status = "completed", + Result = resultData, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + yield return new StreamingOutputItemDone + { + SequenceNumber = seq.Increment(), + OutputIndex = outputIndex, + Item = item + }; + } + else if (workflowEvent is ExecutorFailedEvent failedEvent) + { + // Reuse the item ID from the invoked event, or generate a new one if not found + var itemId = executorItemIds.TryGetValue(failedEvent.ExecutorId, out var existingId) + ? existingId + : IdGenerator.NewId(prefix: "item"); + + // Remove from tracking as this executor run has now failed + executorItemIds.Remove(failedEvent.ExecutorId); + + var item = new ExecutorActionItemResource + { + Id = itemId, + ExecutorId = failedEvent.ExecutorId, + Status = "failed", + Error = failedEvent.Data?.ToString(), + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + yield return new StreamingOutputItemDone + { + SequenceNumber = seq.Increment(), + OutputIndex = outputIndex, + Item = item + }; + } + else + { + // For other workflow events (not executor-specific), keep the old format as fallback + yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex); + } continue; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs index 571e45fa1f5..0ca5c05d9b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemResourceConverter.cs @@ -45,6 +45,7 @@ internal sealed class ItemResourceConverter : JsonConverter MCPApprovalRequestItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalRequestItemResource), MCPApprovalResponseItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalResponseItemResource), MCPCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPCallItemResource), + ExecutorActionItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ExecutorActionItemResource), _ => null }; } @@ -106,6 +107,9 @@ public override void Write(Utf8JsonWriter writer, ItemResource value, JsonSerial case MCPCallItemResource mcpCall: JsonSerializer.Serialize(writer, mcpCall, OpenAIHostingJsonContext.Default.MCPCallItemResource); break; + case ExecutorActionItemResource executorAction: + JsonSerializer.Serialize(writer, executorAction, OpenAIHostingJsonContext.Default.ExecutorActionItemResource); + break; default: throw new JsonException($"Unknown item type: {value.GetType().Name}"); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs index 0a543e1be9d..289bafbc437 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemResource.cs @@ -888,3 +888,47 @@ internal sealed class MCPCallItemResource : ItemResource [JsonPropertyName("error")] public string? Error { get; init; } } + +/// +/// An executor action item resource for workflow execution visualization. +/// +internal sealed class ExecutorActionItemResource : ItemResource +{ + /// + /// The constant item type identifier for executor action items. + /// + public const string ItemType = "executor_action"; + + /// + public override string Type => ItemType; + + /// + /// The executor identifier. + /// + [JsonPropertyName("executor_id")] + public required string ExecutorId { get; init; } + + /// + /// The execution status: "in_progress", "completed", "failed", or "cancelled". + /// + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// + /// The executor result data (for completed status). + /// + [JsonPropertyName("result")] + public JsonElement? Result { get; init; } + + /// + /// The error message (for failed status). + /// + [JsonPropertyName("error")] + public string? Error { get; init; } + + /// + /// The creation timestamp. + /// + [JsonPropertyName("created_at")] + public long CreatedAt { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs index 6d41e10aff5..f39c6e4bcae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/StreamingResponseEvent.cs @@ -565,7 +565,7 @@ internal sealed class StreamingWorkflowEventComplete : StreamingResponseEvent /// /// The constant event type identifier for workflow event events. /// - public const string EventType = "response.workflow_event.complete"; + public const string EventType = "response.workflow_event.completed"; /// [JsonIgnore] diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs new file mode 100644 index 00000000000..5bb2f5e2374 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides configuration options for . +/// +public class ChatForwardingExecutorOptions +{ + /// + /// Gets or sets the chat role to use when converting string messages to instances. + /// If set, the executor will accept string messages and convert them to chat messages with this role. + /// + public ChatRole? StringMessageChatRole { get; set; } +} + +/// +/// A ChatProtocol executor that forwards all messages it receives. Useful for splitting inputs into parallel +/// processing paths. +/// +/// This executor is designed to be cross-run shareable and can be reset to its initial state. It handles +/// multiple chat-related types, enabling flexible message forwarding scenarios. Thread safety and reusability are +/// ensured by its design. +/// The unique identifier for the executor instance. Used to distinguish this executor within the system. +/// Optional configuration settings for the executor. If null, default options are used. +public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOptions? options = null) : Executor(id, declareCrossRunShareable: true), IResettableExecutor +{ + private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole; + + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + if (this._stringMessageChatRole.HasValue) + { + routeBuilder = routeBuilder.AddHandler( + (message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))); + } + + return routeBuilder.AddHandler(ForwardMessageAsync) + .AddHandler>(ForwardMessagesAsync) + .AddHandler(ForwardMessagesAsync) + .AddHandler>(ForwardMessagesAsync) + .AddHandler(ForwardTurnTokenAsync); + } + + private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + // Note that this can be used to split a turn into multiple parallel turns taken, which will cause streaming ChatMessages + // to overlap. + private static ValueTask ForwardTurnTokenAsync(TurnToken message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + // TODO: This is not ideal, but until we have a way of guaranteeing correct routing of interfaces across serialization + // boundaries, we need to do type unification. It behaves better when used as a handler in ChatProtocolExecutor because + // it is a strictly contravariant use, whereas this forces invariance on the type because it is directly forwarded. + private static ValueTask ForwardMessagesAsync(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(messages is List messageList ? messageList : messages.ToList(), cancellationToken); + + private static ValueTask ForwardMessagesAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(messages, cancellationToken); + + /// + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs deleted file mode 100644 index b395dd4216d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Specialized; - -/// Executor that forwards all messages. -internal sealed class ChatForwardingExecutor(string id) : Executor(id, declareCrossRunShareable: true), IResettableExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken)) - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken)) - .AddHandler>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken)) - .AddHandler((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken)); - - public ValueTask ResetAsync() => default; -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs index 06045343c16..6ce89101a0d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs @@ -1282,6 +1282,312 @@ public async Task GetStreamingResponseAsync_EnsuresConversationIdIsNull_ForInner // 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 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 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 messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, [dataContent]) + ]; + + // Act & Assert + InvalidOperationException ex = await Assert.ThrowsAsync(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 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 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 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 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 messages = [new ChatMessage(ChatRole.User, "Test")]; + + // Act + List 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(jsonText); + Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString()); + Assert.Equal(5, deserializedState.GetProperty("step").GetInt32()); + } } internal sealed class TestDelegatingHandler : DelegatingHandler @@ -1376,3 +1682,58 @@ private static HttpResponseMessage CreateResponse(BaseEvent[] events) }; } } + +internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler +{ + private readonly Queue>> _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 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) + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index c980dbd6455..3f6df1eeebd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -369,4 +369,412 @@ public async Task AsChatResponseUpdatesAsync_WithMultipleSequentialToolCalls_Pro 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 events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateSnapshotEvent { Snapshot = stateSnapshot }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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(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(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 events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateSnapshotEvent { Snapshot = null }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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 events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateSnapshotEvent { Snapshot = emptyState }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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(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 events = + [ + new StateSnapshotEvent { Snapshot = stateSnapshot } + ]; + + // Act + List updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + // Assert + ChatResponseUpdate stateUpdate = updates.First(); + DataContent dataContent = Assert.IsType(stateUpdate.Contents[0]); + string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); + JsonElement roundTrippedState = JsonSerializer.Deserialize(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 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 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 events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateDeltaEvent { Delta = stateDelta }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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(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(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 events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateDeltaEvent { Delta = null }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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(updates[0]); // Run started + Assert.IsType(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()); + List events = + [ + new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, + new StateDeltaEvent { Delta = emptyDelta }, + new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } + ]; + + // Act + List 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 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 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 updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [dataContent]) + { + MessageId = "msg1" + } + ]; + + // Act + List outputEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + outputEvents.Add(evt); + } + + // Assert + StateDeltaEvent? deltaEvent = outputEvents.OfType().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 updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" }, + new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" } + ]; + + // Act + List 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 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 updates = []; + await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + updates.Add(update); + } + + List roundTripEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + roundTripEvents.Add(evt); + } + + // Assert + StateDeltaEvent? roundTripDelta = roundTripEvents.OfType().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 } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 40901a49698..32560949fb6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -18,7 +18,12 @@ public void CloningConstructorCopiesProperties() var options = new AgentRunOptions { ContinuationToken = new object(), - AllowBackgroundResponses = true + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + } }; // Act @@ -28,6 +33,10 @@ public void CloningConstructorCopiesProperties() Assert.NotNull(clone); Assert.Same(options.ContinuationToken, clone.ContinuationToken); Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses); + Assert.NotNull(clone.AdditionalProperties); + Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value1", clone.AdditionalProperties["key1"]); + Assert.Equal(42, clone.AdditionalProperties["key2"]); } [Fact] @@ -42,7 +51,12 @@ public void JsonSerializationRoundtrips() var options = new AgentRunOptions { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), - AllowBackgroundResponses = true + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + } }; // Act @@ -54,5 +68,13 @@ public void JsonSerializationRoundtrips() Assert.NotNull(deserialized); Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), deserialized!.ContinuationToken); Assert.Equal(options.AllowBackgroundResponses, deserialized.AllowBackgroundResponses); + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Equal(2, deserialized.AdditionalProperties.Count); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key1", out object? value1)); + Assert.IsType(value1); + Assert.Equal("value1", ((JsonElement)value1!).GetString()); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key2", out object? value2)); + Assert.IsType(value2); + Assert.Equal(42, ((JsonElement)value2!).GetInt32()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs new file mode 100644 index 00000000000..48cb19789ae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs @@ -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 +{ + /// + /// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated. + /// + [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() 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(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs index 1ae0dda9083..a848528888d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs @@ -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 void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() var result = app.MapA2A(agentBuilder, "/a2a", agentCard); Assert.NotNull(result); } - - private sealed class DummyChatClient : IChatClient - { - public void Dispose() - { - throw new NotImplementedException(); - } - - public Task GetResponseAsync(IEnumerable 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 GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Internal/DummyChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Internal/DummyChatClient.cs new file mode 100644 index 00000000000..efab140b684 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Internal/DummyChatClient.cs @@ -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 GetResponseAsync(IEnumerable 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 GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj index 63387ae4580..07dde4f8027 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj @@ -1,4 +1,4 @@ - + $(ProjectsCoreTargetFrameworks) @@ -6,6 +6,9 @@ + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs new file mode 100644 index 00000000000..47d9e63520c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs @@ -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 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().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(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 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 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().FirstOrDefault(dc => dc.MediaType == "application/json"); + string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); + JsonElement receivedState = JsonSerializer.Deserialize(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 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().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 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().FirstOrDefault(dc => dc.MediaType == "application/json"); + string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray()); + JsonElement secondState = JsonSerializer.Deserialize(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 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 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().FirstOrDefault(dc => dc.MediaType == "application/json"); + string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); + JsonElement receivedState = JsonSerializer.Deserialize(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() 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable 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 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; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 9a2f8ac763d..e5fb2061479 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -190,6 +190,264 @@ AIAgent factory(IEnumerable messages, IEnumerable tools, IE 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 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 events = ParseSseEvents(responseContent); + List 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 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 messages, IEnumerable tools, IEnumerable> 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 events = ParseSseEvents(responseContent); + List 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 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 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 ParseSseEvents(string responseContent) + { + List 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable 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, IEnumerable>, JsonElement, AIAgent> factory) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/request.json new file mode 100644 index 00000000000..b41ac7ab2e4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/request.json @@ -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" ] + } + } + } + ] +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/response.json new file mode 100644 index 00000000000..b86280bca05 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/tools/response.json @@ -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" +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj index bd98aebed2b..7d64f7ae2b0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj @@ -27,16 +27,4 @@ - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs index b777db0ce51..8a383890355 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs @@ -456,6 +456,136 @@ public async Task JsonModeRequestResponseAsync() 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() { + { "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); + } + /// /// Helper to parse chat completion chunks from SSE response. /// diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 90d8627fc55..500c0b45cda 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251111] - 2025-11-11 + +### Added + +- **agent-framework-core**: Add OpenAI Responses Image Generation Stream Support with partial images and unit tests ([#1853](https://github.com/microsoft/agent-framework/pull/1853)) +- **agent-framework-ag-ui**: Add concrete AGUIChatClient implementation ([#2072](https://github.com/microsoft/agent-framework/pull/2072)) + +### Fixed + +- **agent-framework-a2a**: Use the last entry in the task history to avoid empty responses ([#2101](https://github.com/microsoft/agent-framework/pull/2101)) +- **agent-framework-core**: Fix MCP Tool Parameter Descriptions not propagated to LLMs ([#1978](https://github.com/microsoft/agent-framework/pull/1978)) +- **agent-framework-core**: Handle agent user input request in AgentExecutor ([#2022](https://github.com/microsoft/agent-framework/pull/2022)) +- **agent-framework-core**: Fix Model ID attribute not showing up in `invoke_agent` span ([#2061](https://github.com/microsoft/agent-framework/pull/2061)) +- **agent-framework-core**: Fix underlying tool choice bug and enable return to previous Handoff subagent ([#2037](https://github.com/microsoft/agent-framework/pull/2037)) + ## [1.0.0b251108] - 2025-11-08 ### Added @@ -189,7 +204,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD +[1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111 [1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108 [1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1 [1.0.0b251106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251105...python-1.0.0b251106 diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 68694e3db47..7fe4649a65b 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -388,6 +388,17 @@ def _task_to_chat_messages(self, task: Task) -> list[ChatMessage]: if task.artifacts is not None: for artifact in task.artifacts: messages.append(self._artifact_to_chat_message(artifact)) + elif task.history is not None and len(task.history) > 0: + # Include the last history item as the agent response + history_item = task.history[-1] + contents = self._a2a_parts_to_contents(history_item.parts) + messages.append( + ChatMessage( + role=Role.ASSISTANT if history_item.role == A2ARole.agent else Role.USER, + contents=contents, + raw_representation=history_item, + ) + ) return messages diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 96e99fa8340..2780bdd481a 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index a7e24b5fbc1..2b02b610904 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -10,6 +10,8 @@ pip install agent-framework-ag-ui ## Quick Start +### Server (Host an AI Agent) + ```python from fastapi import FastAPI from agent_framework import ChatAgent @@ -23,6 +25,7 @@ agent = ChatAgent( chat_client=AzureOpenAIChatClient( endpoint="https://your-resource.openai.azure.com/", deployment_name="gpt-4o-mini", + api_key="your-api-key", ), ) @@ -33,9 +36,38 @@ add_agent_framework_fastapi_endpoint(app, agent, "/") # Run with: uvicorn main:app --reload ``` +### Client (Connect to an AG-UI Server) + +```python +import asyncio +from agent_framework import TextContent +from agent_framework_ag_ui import AGUIChatClient + +async def main(): + async with AGUIChatClient(endpoint="http://localhost:8000/") as client: + # Stream responses + async for update in client.get_streaming_response("Hello!"): + for content in update.contents: + if isinstance(content, TextContent): + print(content.text, end="", flush=True) + print() + +asyncio.run(main()) +``` + +The `AGUIChatClient` supports: +- Streaming and non-streaming responses +- Hybrid tool execution (client-side + server-side tools) +- Automatic thread management for conversation continuity +- Integration with `ChatAgent` for client-side history management + ## Documentation -- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client +- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients + - Server setup with FastAPI + - Client examples using `AGUIChatClient` + - Hybrid tool execution (client-side + server-side) + - Thread management and conversation continuity - **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features ## Features diff --git a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py index 1adedb26494..143f2499a01 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py @@ -5,6 +5,7 @@ import importlib.metadata from ._agent import AgentFrameworkAgent +from ._client import AGUIChatClient from ._confirmation_strategies import ( ConfirmationStrategy, DefaultConfirmationStrategy, @@ -13,6 +14,8 @@ TaskPlannerConfirmationStrategy, ) from ._endpoint import add_agent_framework_fastapi_endpoint +from ._event_converters import AGUIEventConverter +from ._http_service import AGUIHttpService try: __version__ = importlib.metadata.version(__name__) @@ -22,6 +25,9 @@ __all__ = [ "AgentFrameworkAgent", "add_agent_framework_fastapi_endpoint", + "AGUIChatClient", + "AGUIEventConverter", + "AGUIHttpService", "ConfirmationStrategy", "DefaultConfirmationStrategy", "TaskPlannerConfirmationStrategy", diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py new file mode 100644 index 00000000000..ab7eb53940f --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -0,0 +1,407 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI Chat Client implementation.""" + +import json +import logging +import uuid +from collections.abc import AsyncIterable, MutableSequence +from functools import wraps +from typing import Any, TypeVar, cast + +import httpx +from agent_framework import ( + AIFunction, + BaseChatClient, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + DataContent, + FunctionCallContent, +) +from agent_framework._middleware import use_chat_middleware +from agent_framework._tools import use_function_invocation +from agent_framework._types import BaseContent, Contents +from agent_framework.observability import use_observability + +from ._event_converters import AGUIEventConverter +from ._http_service import AGUIHttpService +from ._message_adapters import agent_framework_messages_to_agui +from ._utils import convert_tools_to_agui_format + +logger: logging.Logger = logging.getLogger(__name__) + + +class ServerFunctionCallContent(BaseContent): + """Wrapper for server function calls to prevent client re-execution. + + All function calls from the remote server are server-side executions. + This wrapper prevents @use_function_invocation from trying to execute them again. + """ + + function_call_content: FunctionCallContent + + def __init__(self, function_call_content: FunctionCallContent) -> None: + """Initialize with the function call content.""" + super().__init__(type="server_function_call") + self.function_call_content = function_call_content + + +def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None: + """Replace ServerFunctionCallContent instances with their underlying call content.""" + for idx, content in enumerate(contents): + if isinstance(content, ServerFunctionCallContent): + contents[idx] = content.function_call_content # type: ignore[assignment] + + +TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient]) + + +def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient: + """Class decorator that unwraps server-side function calls after tool handling.""" + + original_get_streaming_response = chat_client.get_streaming_response + + @wraps(original_get_streaming_response) + async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]: + async for update in original_get_streaming_response(self, *args, **kwargs): + _unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents)) + yield update + + chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment] + + original_get_response = chat_client.get_response + + @wraps(original_get_response) + async def response_wrapper(self, *args: Any, **kwargs: Any) -> ChatResponse: + response = await original_get_response(self, *args, **kwargs) + if response.messages: + for message in response.messages: + _unwrap_server_function_call_contents( + cast(MutableSequence[Contents | dict[str, Any]], message.contents) + ) + return response + + chat_client.get_response = response_wrapper # type: ignore[assignment] + return chat_client + + +@_apply_server_function_call_unwrap +@use_function_invocation +@use_observability +@use_chat_middleware +class AGUIChatClient(BaseChatClient): + """Chat client for communicating with AG-UI compliant servers. + + This client implements the BaseChatClient interface and automatically handles: + - Thread ID management for conversation continuity + - State synchronization between client and server + - Server-Sent Events (SSE) streaming + - Event conversion to Agent Framework types + + Important: Message History Management + This client sends exactly the messages it receives to the server. It does NOT + automatically maintain conversation history. The server must handle history via thread_id. + + For stateless servers: Use ChatAgent wrapper which will send full message history on each + request. However, even with ChatAgent, the server must echo back all context for the + agent to maintain history across turns. + + Important: Tool Handling (Hybrid Execution - matches .NET) + 1. Client tool metadata sent to server - LLM knows about both client and server tools + 2. Server has its own tools that execute server-side + 3. When LLM calls a client tool, @use_function_invocation executes it locally + 4. Both client and server tools work together (hybrid pattern) + + The wrapping ChatAgent's @use_function_invocation handles client tool execution + automatically when the server's LLM decides to call them. + + Examples: + Direct usage (server manages thread history): + + .. code-block:: python + + from agent_framework.ag_ui import AGUIChatClient + + client = AGUIChatClient(endpoint="http://localhost:8888/") + + # First message - thread ID auto-generated + response = await client.get_response("Hello!") + thread_id = response.additional_properties.get("thread_id") + + # Second message - server retrieves history using thread_id + response2 = await client.get_response( + "How are you?", + metadata={"thread_id": thread_id} + ) + + Recommended usage with ChatAgent (client manages history): + + .. code-block:: python + + from agent_framework import ChatAgent + from agent_framework.ag_ui import AGUIChatClient + + client = AGUIChatClient(endpoint="http://localhost:8888/") + agent = ChatAgent(name="assistant", client=client) + thread = await agent.get_new_thread() + + # ChatAgent automatically maintains history and sends full context + response = await agent.run("Hello!", thread=thread) + response2 = await agent.run("How are you?", thread=thread) + + Streaming usage: + + .. code-block:: python + + async for update in client.get_streaming_response("Tell me a story"): + if update.contents: + for content in update.contents: + if hasattr(content, "text"): + print(content.text, end="", flush=True) + + Context manager: + + .. code-block:: python + + async with AGUIChatClient(endpoint="http://localhost:8888/") as client: + response = await client.get_response("Hello!") + print(response.messages[0].text) + """ + + OTEL_PROVIDER_NAME = "agui" + + def __init__( + self, + *, + endpoint: str, + http_client: httpx.AsyncClient | None = None, + timeout: float = 60.0, + additional_properties: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the AG-UI chat client. + + Args: + endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/") + http_client: Optional httpx.AsyncClient instance. If None, one will be created. + timeout: Request timeout in seconds (default: 60.0) + additional_properties: Additional properties to store + **kwargs: Additional arguments passed to BaseChatClient + """ + super().__init__(additional_properties=additional_properties, **kwargs) + self._http_service = AGUIHttpService( + endpoint=endpoint, + http_client=http_client, + timeout=timeout, + ) + + async def close(self) -> None: + """Close the HTTP client.""" + await self._http_service.close() + + async def __aenter__(self) -> "AGUIChatClient": + """Enter async context manager.""" + return self + + async def __aexit__(self, *args: Any) -> None: + """Exit async context manager.""" + await self.close() + + def _register_server_tool_placeholder(self, tool_name: str) -> None: + """Register a declaration-only placeholder so function invocation skips execution.""" + + config = getattr(self, "function_invocation_configuration", None) + if not config: + return + if any(getattr(tool, "name", None) == tool_name for tool in config.additional_tools): + return + + placeholder: AIFunction[Any, Any] = AIFunction( + name=tool_name, + description="Server-managed tool placeholder (AG-UI)", + func=None, + ) + config.additional_tools = list(config.additional_tools) + [placeholder] + registered: set[str] = getattr(self, "_registered_server_tools", set()) + registered.add(tool_name) + self._registered_server_tools = registered # type: ignore[attr-defined] + from agent_framework._logging import get_logger + + logger = get_logger() + logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}") + + def _extract_state_from_messages( + self, messages: MutableSequence[ChatMessage] + ) -> tuple[list[ChatMessage], dict[str, Any] | None]: + """Extract state from last message if present. + + Args: + messages: List of chat messages + + Returns: + Tuple of (messages_without_state, state_dict) + """ + if not messages: + return list(messages), None + + last_message = messages[-1] + + for content in last_message.contents: + if isinstance(content, DataContent) and content.media_type == "application/json": + try: + uri = content.uri + if uri.startswith("data:application/json;base64,"): + import base64 + + encoded_data = uri.split(",", 1)[1] + decoded_bytes = base64.b64decode(encoded_data) + state = json.loads(decoded_bytes.decode("utf-8")) + + messages_without_state = list(messages[:-1]) if len(messages) > 1 else [] + return messages_without_state, state + except (json.JSONDecodeError, ValueError, KeyError) as e: + from agent_framework._logging import get_logger + + logger = get_logger() + logger.warning(f"Failed to extract state from message: {e}") + + return list(messages), None + + def _convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]: + """Convert Agent Framework messages to AG-UI format. + + Args: + messages: List of ChatMessage objects + + Returns: + List of AG-UI formatted message dictionaries + """ + return agent_framework_messages_to_agui(messages) + + def _get_thread_id(self, chat_options: ChatOptions) -> str: + """Get or generate thread ID from chat options. + + Args: + chat_options: Chat options containing metadata + + Returns: + Thread ID string + """ + thread_id = None + if chat_options.metadata: + thread_id = chat_options.metadata.get("thread_id") + + if not thread_id: + thread_id = f"thread_{uuid.uuid4().hex}" + + return thread_id + + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + """Internal method to get non-streaming response. + + Keyword Args: + messages: List of chat messages + chat_options: Chat options for the request + **kwargs: Additional keyword arguments + + Returns: + ChatResponse object + """ + return await ChatResponse.from_chat_response_generator( + self._inner_get_streaming_response( + messages=messages, + chat_options=chat_options, + **kwargs, + ) + ) + + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + """Internal method to get streaming response. + + Keyword Args: + messages: List of chat messages + chat_options: Chat options for the request + **kwargs: Additional keyword arguments + + Yields: + ChatResponseUpdate objects + """ + messages_to_send, state = self._extract_state_from_messages(messages) + + thread_id = self._get_thread_id(chat_options) + run_id = f"run_{uuid.uuid4().hex}" + + agui_messages = self._convert_messages_to_agui_format(messages_to_send) + + # Send client tools to server so LLM knows about them + # Client tools execute via ChatAgent's @use_function_invocation wrapper + agui_tools = convert_tools_to_agui_format(chat_options.tools) + + # Build set of client tool names (matches .NET clientToolSet) + # Used to distinguish client vs server tools in response stream + client_tool_set: set[str] = set() + if chat_options.tools: + for tool in chat_options.tools: + if hasattr(tool, "name"): + client_tool_set.add(tool.name) # type: ignore[arg-type] + self._last_client_tool_set = client_tool_set # type: ignore[attr-defined] + + logger.debug( + "[AGUIChatClient] Preparing request", + extra={ + "thread_id": thread_id, + "run_id": run_id, + "client_tools": list(client_tool_set), + "messages": [msg.text for msg in messages_to_send if msg.text], + }, + ) + logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}") + + converter = AGUIEventConverter() + + async for event in self._http_service.post_run( + thread_id=thread_id, + run_id=run_id, + messages=agui_messages, + state=state, + tools=agui_tools, + ): + logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}") + update = converter.convert_event(event) + if update is not None: + logger.debug( + "[AGUIChatClient] Converted update", + extra={"role": update.role, "contents": [type(c).__name__ for c in update.contents]}, + ) + # Distinguish client vs server tools + for i, content in enumerate(update.contents): + if isinstance(content, FunctionCallContent): + logger.debug( + f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}" + ) + if content.name in client_tool_set: + # Client tool - let @use_function_invocation execute it + if not content.additional_properties: + content.additional_properties = {} + content.additional_properties["agui_thread_id"] = thread_id + else: + # Server tool - wrap so @use_function_invocation ignores it + logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}") + self._register_server_tool_placeholder(content.name) + update.contents[i] = ServerFunctionCallContent(content) # type: ignore + + yield update diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py b/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py new file mode 100644 index 00000000000..0f485739c95 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Event converter for AG-UI protocol events to Agent Framework types.""" + +from typing import Any + +from agent_framework import ( + ChatResponseUpdate, + ErrorContent, + FinishReason, + FunctionCallContent, + FunctionResultContent, + Role, + TextContent, +) + + +class AGUIEventConverter: + """Converter for AG-UI events to Agent Framework types. + + Handles conversion of AG-UI protocol events to ChatResponseUpdate objects + while maintaining state, aggregating content, and tracking metadata. + """ + + def __init__(self) -> None: + """Initialize the converter with fresh state.""" + self.current_message_id: str | None = None + self.current_tool_call_id: str | None = None + self.current_tool_name: str | None = None + self.accumulated_tool_args: str = "" + self.thread_id: str | None = None + self.run_id: str | None = None + + def convert_event(self, event: dict[str, Any]) -> ChatResponseUpdate | None: + """Convert a single AG-UI event to ChatResponseUpdate. + + Args: + event: AG-UI event dictionary + + Returns: + ChatResponseUpdate if event produces content, None otherwise + + Examples: + RUN_STARTED event: + + .. code-block:: python + + converter = AGUIEventConverter() + event = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"} + update = converter.convert_event(event) + assert update.additional_properties["thread_id"] == "t1" + + TEXT_MESSAGE_CONTENT event: + + .. code-block:: python + + event = {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hello"} + update = converter.convert_event(event) + assert update.contents[0].text == "Hello" + """ + event_type = event.get("type", "") + + if event_type == "RUN_STARTED": + return self._handle_run_started(event) + elif event_type == "TEXT_MESSAGE_START": + return self._handle_text_message_start(event) + elif event_type == "TEXT_MESSAGE_CONTENT": + return self._handle_text_message_content(event) + elif event_type == "TEXT_MESSAGE_END": + return self._handle_text_message_end(event) + elif event_type == "TOOL_CALL_START": + return self._handle_tool_call_start(event) + elif event_type == "TOOL_CALL_ARGS": + return self._handle_tool_call_args(event) + elif event_type == "TOOL_CALL_END": + return self._handle_tool_call_end(event) + elif event_type == "TOOL_CALL_RESULT": + return self._handle_tool_call_result(event) + elif event_type == "RUN_FINISHED": + return self._handle_run_finished(event) + elif event_type == "RUN_ERROR": + return self._handle_run_error(event) + + return None + + def _handle_run_started(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle RUN_STARTED event.""" + self.thread_id = event.get("threadId") + self.run_id = event.get("runId") + + return ChatResponseUpdate( + role=Role.ASSISTANT, + contents=[], + additional_properties={ + "thread_id": self.thread_id, + "run_id": self.run_id, + }, + ) + + def _handle_text_message_start(self, event: dict[str, Any]) -> ChatResponseUpdate | None: + """Handle TEXT_MESSAGE_START event.""" + self.current_message_id = event.get("messageId") + return ChatResponseUpdate( + role=Role.ASSISTANT, + message_id=self.current_message_id, + contents=[], + ) + + def _handle_text_message_content(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle TEXT_MESSAGE_CONTENT event.""" + message_id = event.get("messageId") + delta = event.get("delta", "") + + if message_id != self.current_message_id: + self.current_message_id = message_id + + return ChatResponseUpdate( + role=Role.ASSISTANT, + message_id=self.current_message_id, + contents=[TextContent(text=delta)], + ) + + def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None: + """Handle TEXT_MESSAGE_END event.""" + return None + + def _handle_tool_call_start(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle TOOL_CALL_START event.""" + self.current_tool_call_id = event.get("toolCallId") + self.current_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name") + self.accumulated_tool_args = "" + + return ChatResponseUpdate( + role=Role.ASSISTANT, + contents=[ + FunctionCallContent( + call_id=self.current_tool_call_id or "", + name=self.current_tool_name or "", + arguments="", + ) + ], + ) + + def _handle_tool_call_args(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle TOOL_CALL_ARGS event.""" + delta = event.get("delta", "") + self.accumulated_tool_args += delta + + return ChatResponseUpdate( + role=Role.ASSISTANT, + contents=[ + FunctionCallContent( + call_id=self.current_tool_call_id or "", + name=self.current_tool_name or "", + arguments=delta, + ) + ], + ) + + def _handle_tool_call_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None: + """Handle TOOL_CALL_END event.""" + self.accumulated_tool_args = "" + return None + + def _handle_tool_call_result(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle TOOL_CALL_RESULT event.""" + tool_call_id = event.get("toolCallId", "") + result = event.get("result") if event.get("result") is not None else event.get("content") + + return ChatResponseUpdate( + role=Role.TOOL, + contents=[ + FunctionResultContent( + call_id=tool_call_id, + result=result, + ) + ], + ) + + def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle RUN_FINISHED event.""" + return ChatResponseUpdate( + role=Role.ASSISTANT, + finish_reason=FinishReason.STOP, + contents=[], + additional_properties={ + "thread_id": self.thread_id, + "run_id": self.run_id, + }, + ) + + def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate: + """Handle RUN_ERROR event.""" + error_message = event.get("message", "Unknown error") + + return ChatResponseUpdate( + role=Role.ASSISTANT, + finish_reason=FinishReason.CONTENT_FILTER, + contents=[ + ErrorContent( + message=error_message, + error_code="RUN_ERROR", + ) + ], + additional_properties={ + "thread_id": self.thread_id, + "run_id": self.run_id, + }, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index b6b2294d45b..4117fd50bb8 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -107,7 +107,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # Skip text content if we're about to emit confirm_changes # The summary should only appear after user confirms if self.should_stop_after_confirm: - logger.debug(" >>> Skipping text content - waiting for confirm_changes response") + logger.debug("Skipping text content - waiting for confirm_changes response") # Save the summary text to show after confirmation self.suppressed_summary += content.text continue @@ -156,7 +156,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba tool_call_name=content.name, parent_message_id=self.current_message_id, ) - logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'") + logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'") events.append(tool_start_event) # Track tool call for MessagesSnapshotEvent @@ -186,7 +186,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # If it's a dict, convert to JSON delta_str = json.dumps(content.arguments) - logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'") + logger.info(f"Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'") args_event = ToolCallArgsEvent( tool_call_id=tool_call_id, delta=delta_str, @@ -211,7 +211,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba self.streaming_tool_args += json.dumps(content.arguments) logger.debug( - f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'" + f"Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'" ) # Try to parse accumulated arguments (may be incomplete JSON) @@ -262,11 +262,11 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba else str(partial_value) ) logger.info( - f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': " + f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': " f"op=replace, path=/{state_key}, value={value_preview}" ) elif self.state_delta_count % 100 == 0: - logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted") + logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") events.append(state_delta_event) self.last_emitted_state[state_key] = partial_value @@ -312,11 +312,11 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba else str(state_value) ) logger.info( - f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': " + f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': " f"op=replace, path=/{state_key}, value={value_preview}" ) elif self.state_delta_count % 100 == 0: # Also log every 100th - logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted") + logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted") events.append(state_delta_event) @@ -360,7 +360,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba ], ) logger.info( - f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}" + f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}" ) events.append(state_delta_event) @@ -376,13 +376,13 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba end_event = ToolCallEndEvent( tool_call_id=content.call_id, ) - logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") + logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") events.append(end_event) # Log total StateDeltaEvent count for this tool call if self.state_delta_count > 0: logger.info( - f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total" + f"Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total" ) # Reset streaming accumulator and counter for next tool call @@ -410,11 +410,13 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba events.append(result_event) # Track tool result for MessagesSnapshotEvent + # AG-UI protocol expects: { role: "tool", toolCallId: ..., content: ... } + # Use camelCase for Pydantic's alias_generator=to_camel self.tool_results.append( { "id": result_message_id, "role": "tool", - "tool_call_id": content.call_id, + "toolCallId": content.call_id, "content": result_content, } ) @@ -422,6 +424,9 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # Emit MessagesSnapshotEvent with the complete conversation including tool calls and results # This is required for CopilotKit's useCopilotAction to detect tool result if self.pending_tool_calls and self.tool_results: + # Import message adapter + from ._message_adapters import agent_framework_messages_to_agui + # Build assistant message with tool_calls assistant_message = { "id": generate_event_id(), @@ -429,14 +434,19 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba "tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls } + # Convert Agent Framework messages to AG-UI format (adds required 'id' field) + converted_input_messages = agent_framework_messages_to_agui(self.input_messages) + # Build complete messages array: input messages + assistant message + tool results - all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy() + all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() # Emit MessagesSnapshotEvent using the proper event type + # Note: messages are dict[str, Any] but Pydantic will validate them as Message types messages_snapshot_event = MessagesSnapshotEvent( - type=EventType.MESSAGES_SNAPSHOT, messages=all_messages + type=EventType.MESSAGES_SNAPSHOT, + messages=all_messages, # type: ignore[arg-type] ) - logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages") + logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages") events.append(messages_snapshot_event) # After tool execution, emit StateSnapshotEvent if we have pending state updates @@ -466,7 +476,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # If so, emit a confirm_changes tool call for the UI modal tool_was_predictive = False logger.debug( - f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', " + f"Checking predictive state: current_tool='{self.current_tool_call_name}', " f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}" ) for state_key, config in self.predict_state_config.items(): @@ -474,7 +484,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # We need to match against self.current_tool_call_name if self.current_tool_call_name and config["tool"] == self.current_tool_call_name: logger.info( - f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'" + f"Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'" ) tool_was_predictive = True break @@ -483,7 +493,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # Emit confirm_changes tool call sequence confirm_call_id = generate_event_id() - logger.info(" >>> Emitting confirm_changes tool call for predictive update") + logger.info("Emitting confirm_changes tool call for predictive update") # Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED) self.pending_tool_calls.append( @@ -518,6 +528,9 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba events.append(confirm_end) # Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED + # Import message adapter + from ._message_adapters import agent_framework_messages_to_agui + # Build assistant message with pending confirm_changes tool call assistant_message = { "id": generate_event_id(), @@ -525,23 +538,28 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba "tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes } + # Convert Agent Framework messages to AG-UI format (adds required 'id' field) + converted_input_messages = agent_framework_messages_to_agui(self.input_messages) + # Build complete messages array: input messages + assistant message + any tool results - all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy() + all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy() # Emit MessagesSnapshotEvent + # Note: messages are dict[str, Any] but Pydantic will validate them as Message types messages_snapshot_event = MessagesSnapshotEvent( - type=EventType.MESSAGES_SNAPSHOT, messages=all_messages + type=EventType.MESSAGES_SNAPSHOT, + messages=all_messages, # type: ignore[arg-type] ) logger.info( - f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages" + f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages" ) events.append(messages_snapshot_event) # Set flag to stop the run after this - we're waiting for user response self.should_stop_after_confirm = True - logger.info(" >>> Set flag to stop run after confirm_changes") + logger.info("Set flag to stop run after confirm_changes") elif tool_was_predictive: - logger.info(" >>> Skipping confirm_changes - require_confirmation is False") + logger.info("Skipping confirm_changes - require_confirmation is False") # Clear pending updates and reset tool name tracker self.pending_state_updates.clear() @@ -580,7 +598,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba # Update current state self.current_state[state_key] = state_value logger.info( - f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}" + f"Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}" ) # Emit state snapshot @@ -596,7 +614,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba tool_call_id=content.function_call.call_id, ) logger.info( - f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" + f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" ) events.append(end_event) @@ -615,7 +633,7 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba }, }, ) - logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'") + logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") events.append(approval_event) return events diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py new file mode 100644 index 00000000000..3c5b2884547 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""HTTP service for AG-UI protocol communication.""" + +import json +import logging +from collections.abc import AsyncIterable +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class AGUIHttpService: + """HTTP service for AG-UI protocol communication. + + Handles HTTP POST requests and Server-Sent Events (SSE) stream parsing + for the AG-UI protocol. + + Examples: + Basic usage: + + .. code-block:: python + + service = AGUIHttpService("http://localhost:8888/") + async for event in service.post_run( + thread_id="thread_123", + run_id="run_456", + messages=[{"role": "user", "content": "Hello"}] + ): + print(event["type"]) + + With context manager: + + .. code-block:: python + + async with AGUIHttpService("http://localhost:8888/") as service: + async for event in service.post_run(...): + print(event) + """ + + def __init__( + self, + endpoint: str, + http_client: httpx.AsyncClient | None = None, + timeout: float = 60.0, + ) -> None: + """Initialize the HTTP service. + + Args: + endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/") + http_client: Optional httpx AsyncClient. If None, creates a new one. + timeout: Request timeout in seconds (default: 60.0) + """ + self.endpoint = endpoint.rstrip("/") + self._owns_client = http_client is None + self.http_client = http_client or httpx.AsyncClient(timeout=timeout) + + async def post_run( + self, + thread_id: str, + run_id: str, + messages: list[dict[str, Any]], + state: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + ) -> AsyncIterable[dict[str, Any]]: + """Post a run request and stream AG-UI events. + + Args: + thread_id: Thread identifier for conversation continuity + run_id: Unique run identifier + messages: List of messages in AG-UI format + state: Optional state object to send to server + tools: Optional list of tools available to the agent + + Yields: + AG-UI event dictionaries parsed from SSE stream + + Raises: + httpx.HTTPStatusError: If the HTTP request fails + ValueError: If SSE parsing encounters invalid data + + Examples: + .. code-block:: python + + service = AGUIHttpService("http://localhost:8888/") + async for event in service.post_run( + thread_id="thread_abc", + run_id="run_123", + messages=[{"role": "user", "content": "Hello"}], + state={"user_context": {"name": "Alice"}} + ): + if event["type"] == "TEXT_MESSAGE_CONTENT": + print(event["delta"]) + """ + # Build request payload + request_data: dict[str, Any] = { + "thread_id": thread_id, + "run_id": run_id, + "messages": messages, + } + + if state is not None: + request_data["state"] = state + + if tools is not None: + request_data["tools"] = tools + + logger.debug( + f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, " + f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}" + ) + + # Stream the response using SSE + async with self.http_client.stream( + "POST", + self.endpoint, + json=request_data, + headers={"Accept": "text/event-stream"}, + ) as response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP request failed: {e.response.status_code} - {e.response.text}") + raise + + async for line in response.aiter_lines(): + # Parse Server-Sent Events format + if line.startswith("data: "): + data = line[6:] # Remove "data: " prefix + try: + event = json.loads(data) + logger.debug(f"Received event: {event.get('type', 'UNKNOWN')}") + yield event + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse SSE data: {data}. Error: {e}") + # Continue processing other events instead of failing + continue + + async def close(self) -> None: + """Close the HTTP client if owned by this service. + + Only closes the client if it was created by this service instance. + If an external client was provided, it remains the caller's + responsibility to close it. + """ + if self._owns_client and self.http_client: + await self.http_client.aclose() + + async def __aenter__(self) -> "AGUIHttpService": + """Enter async context manager.""" + return self + + async def __aexit__(self, *args: Any) -> None: + """Exit async context manager and clean up resources.""" + await self.close() diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index ebeb2dcacfa..da8cb197f29 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -2,12 +2,13 @@ """Message format conversion between AG-UI and Agent Framework.""" -from typing import Any +from typing import Any, cast from agent_framework import ( ChatMessage, FunctionApprovalResponseContent, FunctionCallContent, + FunctionResultContent, Role, TextContent, ) @@ -46,7 +47,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha result_content = msg.get("result", msg.get("content", "")) chat_msg = ChatMessage( - role=Role.ASSISTANT, # Tool results are assistant messages + role=Role.TOOL, # Tool results must be tool role contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], ) @@ -56,6 +57,42 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha result.append(chat_msg) continue + # If assistant message includes tool calls, convert to FunctionCallContent(s) + tool_calls = msg.get("tool_calls") or msg.get("toolCalls") + if tool_calls: + contents: list[Any] = [] + # Include any assistant text content if present + content_text = msg.get("content") + if isinstance(content_text, str) and content_text: + contents.append(TextContent(text=content_text)) + # Convert each tool call entry + for tc in tool_calls: + if not isinstance(tc, dict): + continue + # Cast to typed dict for proper type inference + tc_dict = cast(dict[str, Any], tc) + tc_type = tc_dict.get("type") + if tc_type == "function": + func_data = tc_dict.get("function", {}) + func_dict = cast(dict[str, Any], func_data) if isinstance(func_data, dict) else {} + + call_id = str(tc_dict.get("id", "")) + name = str(func_dict.get("name", "")) + arguments = func_dict.get("arguments") + + contents.append( + FunctionCallContent( + call_id=call_id, + name=name, + arguments=arguments, + ) + ) + chat_msg = ChatMessage(role=Role.ASSISTANT, contents=contents) + if "id" in msg: + chat_msg.message_id = msg["id"] + result.append(chat_msg) + continue + role_str = msg.get("role", "user") # Handle tool result messages (with role="tool") @@ -78,11 +115,11 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha # Backend tool results have non-empty content WITHOUT "accepted" field if tool_call_id and result_content and not is_approval: - # Backend tool execution - convert to FunctionResultContent + # Tool execution result - convert to FunctionResultContent with correct role from agent_framework import FunctionResultContent chat_msg = ChatMessage( - role=Role.ASSISTANT, # Tool results are assistant messages + role=Role.TOOL, contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], ) @@ -97,9 +134,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha chat_msg = ChatMessage( role=Role.USER, # Approval responses are user messages contents=[TextContent(text=content)], + additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")}, ) - # Mark this as a tool result so we can detect it later - chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined] if "id" in msg: chat_msg.message_id = msg["id"] @@ -112,7 +148,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha # Check if this message contains function approvals if "function_approvals" in msg and msg["function_approvals"]: # Convert function approvals to FunctionApprovalResponseContent - contents: list[Any] = [] + approval_contents: list[Any] = [] for approval in msg["function_approvals"]: # Create FunctionCallContent with the modified arguments func_call = FunctionCallContent( @@ -127,9 +163,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha id=approval.get("id", ""), function_call=func_call, ) - contents.append(approval_response) + approval_contents.append(approval_response) - chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type] + chat_msg = ChatMessage(role=role, contents=approval_contents) # type: ignore[arg-type] else: # Regular text message content = msg.get("content", "") @@ -146,21 +182,44 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha return result -def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]: +def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str, Any]]) -> list[dict[str, Any]]: """Convert Agent Framework messages to AG-UI format. Args: - messages: List of Agent Framework ChatMessage objects + messages: List of Agent Framework ChatMessage objects or AG-UI dicts (already converted) Returns: List of AG-UI message dictionaries """ + from ._utils import generate_event_id + result: list[dict[str, Any]] = [] for msg in messages: + # If already a dict (AG-UI format), ensure it has an ID and normalize keys for Pydantic + if isinstance(msg, dict): + # Always work on a copy to avoid mutating input + normalized_msg = msg.copy() + # Ensure ID exists + if "id" not in normalized_msg: + normalized_msg["id"] = generate_event_id() + # Normalize tool_call_id to toolCallId for Pydantic's alias_generator=to_camel + if normalized_msg.get("role") == "tool": + if "tool_call_id" in normalized_msg: + normalized_msg["toolCallId"] = normalized_msg["tool_call_id"] + del normalized_msg["tool_call_id"] + elif "toolCallId" not in normalized_msg: + # Tool message missing toolCallId - add empty string to satisfy schema + normalized_msg["toolCallId"] = "" + # Always append the normalized copy, not the original + result.append(normalized_msg) + continue + + # Convert ChatMessage to AG-UI format role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user") content_text = "" tool_calls: list[dict[str, Any]] = [] + tool_result_call_id: str | None = None for content in msg.contents: if isinstance(content, TextContent): @@ -176,18 +235,32 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[s }, } ) + elif isinstance(content, FunctionResultContent): + # Tool result content - extract call_id and result + tool_result_call_id = content.call_id + # Serialize result to string + if isinstance(content.result, dict): + import json + + content_text = json.dumps(content.result) # type: ignore + elif content.result is not None: + content_text = str(content.result) agui_msg: dict[str, Any] = { + "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id "role": role, "content": content_text, } - if msg.message_id: - agui_msg["id"] = msg.message_id - if tool_calls: agui_msg["tool_calls"] = tool_calls + # If this is a tool result message, add toolCallId (using camelCase for Pydantic) + if tool_result_call_id: + agui_msg["toolCallId"] = tool_result_call_id + # Tool result messages should have role="tool" + agui_msg["role"] = "tool" + result.append(agui_msg) return result diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py index 1440dddf36c..b5da7998ca5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -16,9 +16,9 @@ TextMessageEndEvent, TextMessageStartEvent, ) -from agent_framework import AgentProtocol, AgentThread, TextContent +from agent_framework import AgentProtocol, AgentThread, ChatAgent, TextContent -from ._utils import generate_event_id +from ._utils import convert_agui_tools_to_agent_framework, generate_event_id if TYPE_CHECKING: from ._agent import AgentConfig @@ -142,14 +142,10 @@ def can_handle(self, context: ExecutionContext) -> bool: True if last message is a tool result """ msg = context.last_message - if not msg or not hasattr(msg, "metadata"): + if not msg: return False - metadata = getattr(msg, "metadata", None) - if not metadata: - return False - - return bool(metadata.get("is_tool_result", False)) + return bool(msg.additional_properties.get("is_tool_result", False)) async def run( self, @@ -274,8 +270,10 @@ async def run( current_state: dict[str, Any] = initial_state.copy() if initial_state else {} # Check if agent uses structured outputs (response_format) - chat_options = getattr(context.agent, "chat_options", None) - response_format = getattr(chat_options, "response_format", None) if chat_options else None + # Use isinstance to narrow type for proper attribute access + response_format = None + if isinstance(context.agent, ChatAgent): + response_format = context.agent.chat_options.response_format skip_text_content = response_format is not None # Create event bridge @@ -334,9 +332,8 @@ async def run( if context.messages: await thread.on_new_messages(context.messages) - # Get the last message as the new input - new_message = context.last_message - if not new_message: + # Use the full incoming message batch to preserve tool-call adjacency + if not context.messages: logger.warning("No messages provided in AG-UI input") yield event_bridge.create_run_finished_event() return @@ -362,11 +359,68 @@ async def run( ) messages_to_run.append(state_context_msg) - messages_to_run.append(new_message) + # Preserve order from client to satisfy provider constraints (assistant tool_calls must + # immediately precede tool result messages). Using the full batch avoids reordering. + messages_to_run.extend(context.messages) + + # Handle client tools for hybrid execution + # Client sends tool metadata, server merges with its own tools. + # Client tools have func=None (declaration-only), so @use_function_invocation + # will return the function call without executing (passes back to client). + from agent_framework import BaseChatClient + + client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools")) + + # Extract server tools - use type narrowing when possible + server_tools: list[Any] = [] + if isinstance(context.agent, ChatAgent): + server_tools = context.agent.chat_options.tools or [] + else: + # AgentProtocol allows duck-typed implementations - fallback to attribute access + # This supports test mocks and custom agent implementations + try: + chat_options_attr = getattr(context.agent, "chat_options", None) + if chat_options_attr is not None: + server_tools = getattr(chat_options_attr, "tools", None) or [] + except AttributeError: + pass + + # Register client tools as additional (declaration-only) so they are not executed on server + if client_tools: + if isinstance(context.agent, ChatAgent): + # Type-safe path for ChatAgent + chat_client = context.agent.chat_client + if ( + isinstance(chat_client, BaseChatClient) + and chat_client.function_invocation_configuration is not None + ): + chat_client.function_invocation_configuration.additional_tools = client_tools + logger.debug( + f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)" + ) + else: + # Fallback for AgentProtocol implementations (test mocks, custom agents) + try: + chat_client_attr = getattr(context.agent, "chat_client", None) + if chat_client_attr is not None: + fic = getattr(chat_client_attr, "function_invocation_configuration", None) + if fic is not None: + fic.additional_tools = client_tools # type: ignore[attr-defined] + logger.debug( + f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)" + ) + except AttributeError: + pass + + combined_tools: list[Any] = [] + if server_tools: + combined_tools.extend(server_tools) + if client_tools: + combined_tools.extend(client_tools) # Collect all updates to get the final structured output all_updates: list[Any] = [] - async for update in context.agent.run_stream(messages_to_run, thread=thread): + async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=combined_tools or None): all_updates.append(update) events = await event_bridge.from_agent_run_update(update) for event in events: @@ -374,7 +428,7 @@ async def run( # After agent completes, check if we should stop (waiting for user to confirm changes) if event_bridge.should_stop_after_confirm: - logger.info(" >>> Stopping run after confirm_changes - waiting for user response") + logger.info("Stopping run after confirm_changes - waiting for user response") yield event_bridge.create_run_finished_event() return diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index e30d682fcbd..8b271988dcb 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -4,10 +4,13 @@ import copy import uuid +from collections.abc import Callable, MutableMapping, Sequence from dataclasses import asdict, is_dataclass from datetime import date, datetime from typing import Any +from agent_framework import AIFunction, ToolProtocol + def generate_event_id() -> str: """Generate a unique event ID.""" @@ -55,3 +58,109 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401 if isinstance(obj, dict): return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc] return str(obj) + + +def convert_agui_tools_to_agent_framework( + agui_tools: list[dict[str, Any]] | None, +) -> list[AIFunction[Any, Any]] | None: + """Convert AG-UI tool definitions to Agent Framework AIFunction declarations. + + Creates declaration-only AIFunction instances (no executable implementation). + These are used to tell the LLM about available tools. The actual execution + happens on the client side via @use_function_invocation. + + CRITICAL: These tools MUST have func=None so that declaration_only returns True. + This prevents the server from trying to execute client-side tools. + + Args: + agui_tools: List of AG-UI tool definitions with name, description, parameters + + Returns: + List of AIFunction declarations, or None if no tools provided + """ + if not agui_tools: + return None + + result: list[AIFunction[Any, Any]] = [] + for tool_def in agui_tools: + # Create declaration-only AIFunction (func=None means no implementation) + # When func=None, the declaration_only property returns True, + # which tells @use_function_invocation to return the function call + # without executing it (so it can be sent back to the client) + func: AIFunction[Any, Any] = AIFunction( + name=tool_def.get("name", ""), + description=tool_def.get("description", ""), + func=None, # CRITICAL: Makes declaration_only=True + input_model=tool_def.get("parameters", {}), + ) + result.append(func) + + return result + + +def convert_tools_to_agui_format( + tools: ( + ToolProtocol + | Callable[..., Any] + | MutableMapping[str, Any] + | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | None + ), +) -> list[dict[str, Any]] | None: + """Convert tools to AG-UI format. + + This sends only the metadata (name, description, JSON schema) to the server. + The actual executable implementation stays on the client side. + The @use_function_invocation decorator handles client-side execution when + the server requests a function. + + Args: + tools: Tools to convert (single tool or sequence of tools) + + Returns: + List of tool specifications in AG-UI format, or None if no tools provided + """ + if not tools: + return None + + # Normalize to list + if not isinstance(tools, list): + tool_list: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item] + else: + tool_list = tools # type: ignore[assignment] + + results: list[dict[str, Any]] = [] + + for tool in tool_list: + if isinstance(tool, dict): + # Already in dict format, pass through + results.append(tool) # type: ignore[arg-type] + elif isinstance(tool, AIFunction): + # Convert AIFunction to AG-UI tool format + results.append( + { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters(), + } + ) + elif callable(tool): + # Convert callable to AIFunction first, then to AG-UI format + from agent_framework import ai_function + + ai_func = ai_function(tool) + results.append( + { + "name": ai_func.name, + "description": ai_func.description, + "parameters": ai_func.parameters(), + } + ) + elif isinstance(tool, ToolProtocol): + # Handle other ToolProtocol implementations + # For now, we'll skip non-AIFunction tools as they may not have + # the parameters() method. This matches .NET behavior which only + # converts AIFunctionDeclaration instances. + continue + + return results if results else None diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index 88887f6070a..cd9c3c71c7d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -14,7 +14,7 @@ pip install agent-framework-ag-ui from fastapi import FastAPI from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = ChatAgent( @@ -104,7 +104,7 @@ State is injected as system messages and updated via predictive state updates: ```python from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import AgentFrameworkAgent +from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = ChatAgent( @@ -141,7 +141,7 @@ Predictive state updates automatically stream tool arguments as optimistic state ```python from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import AgentFrameworkAgent +from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = ChatAgent( @@ -170,7 +170,7 @@ Provide domain-specific confirmation messages: from typing import Any from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import AgentFrameworkAgent, ConfirmationStrategy +from agent_framework.ag_ui import AgentFrameworkAgent, ConfirmationStrategy class CustomConfirmationStrategy(ConfirmationStrategy): def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: @@ -216,7 +216,7 @@ def sensitive_action(param: str) -> str: Add custom execution flows by implementing the Orchestrator pattern: ```python -from agent_framework_ag_ui._orchestrators import Orchestrator, ExecutionContext +from agent_framework.ag_ui._orchestrators import Orchestrator, ExecutionContext class MyCustomOrchestrator(Orchestrator): def can_handle(self, context: ExecutionContext) -> bool: diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py index ef7a438d9b5..a2856dbf23e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py @@ -128,7 +128,7 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non import uuid logger = logging.getLogger(__name__) - logger.info(">>> TaskStepsAgentWithExecution.run_agent() called - wrapper is active") + logger.info("TaskStepsAgentWithExecution.run_agent() called - wrapper is active") # First, run the base agent to generate the plan - buffer text messages final_state: dict[str, Any] | None = None @@ -138,41 +138,41 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non async for event in self._base_agent.run_agent(input_data): event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__ - logger.info(f">>> Processing event: {event_type_str}") + logger.info(f"Processing event: {event_type_str}") match event: case StateSnapshotEvent(snapshot=snapshot): final_state = snapshot - logger.info(f">>> Captured STATE_SNAPSHOT event with state: {final_state}") + logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}") yield event case RunFinishedEvent(): run_finished_event = event - logger.info(">>> Captured RUN_FINISHED event - will send after step execution and summary") + logger.info("Captured RUN_FINISHED event - will send after step execution and summary") case ToolCallStartEvent(tool_call_id=call_id): tool_call_id = call_id - logger.info(f">>> Captured tool_call_id: {tool_call_id}") + logger.info(f"Captured tool_call_id: {tool_call_id}") yield event case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent(): buffered_text_events.append(event) - logger.info(f">>> Buffered {event_type_str} from first LLM call") + logger.info(f"Buffered {event_type_str} from first LLM call") case _: - logger.info(f">>> Yielding event immediately: {event_type_str}") + logger.info(f"Yielding event immediately: {event_type_str}") yield event - logger.info(f">>> Base agent completed. Final state: {final_state}") + logger.info(f"Base agent completed. Final state: {final_state}") # Now simulate executing the steps if final_state and "steps" in final_state: steps = final_state["steps"] - logger.info(f">>> Starting step execution simulation for {len(steps)} steps") + logger.info(f"Starting step execution simulation for {len(steps)} steps") for i in range(len(steps)): - logger.info(f">>> Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}") + logger.info(f"Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}") await asyncio.sleep(1.0) # Simulate work # Update step to completed steps[i]["status"] = "completed" - logger.info(f">>> Step {i + 1} marked as completed") + logger.info(f"Step {i + 1} marked as completed") # Send delta event with manual JSON patch format delta_event = StateDeltaEvent( @@ -185,7 +185,7 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non } ], ) - logger.info(f">>> Yielding StateDeltaEvent for step {i + 1}") + logger.info(f"Yielding StateDeltaEvent for step {i + 1}") yield delta_event # Send final snapshot @@ -193,11 +193,11 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non type=EventType.STATE_SNAPSHOT, snapshot={"steps": steps}, ) - logger.info(">>> Yielding final StateSnapshotEvent with all steps completed") + logger.info("Yielding final StateSnapshotEvent with all steps completed") yield final_snapshot # SECOND LLM call: Stream summary from chat client directly - logger.info(">>> Making SECOND LLM call to generate summary after step execution") + logger.info("Making SECOND LLM call to generate summary after step execution") # Get the underlying chat agent and client chat_agent = self._base_agent.agent # type: ignore @@ -236,7 +236,7 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non ) # Stream the LLM response and manually emit text events - logger.info(">>> Calling chat client for summary") + logger.info("Calling chat client for summary") message_id = str(uuid.uuid4()) @@ -268,7 +268,7 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non type=EventType.TEXT_MESSAGE_END, message_id=message_id, ) - logger.info(f">>> Summary complete: {accumulated_text}") + logger.info(f"Summary complete: {accumulated_text}") # Build complete message for persistence summary_message = { @@ -285,7 +285,7 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non messages=final_messages, ) except Exception as e: - logger.error(f">>> Error generating summary: {e}") + logger.error(f"Error generating summary: {e}") # Generate a new message ID for the error error_message_id = str(uuid.uuid4()) # Yield TEXT_MESSAGE_START for error @@ -306,11 +306,11 @@ async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, Non message_id=error_message_id, ) else: - logger.warning(f">>> No steps found in final_state to execute. final_state={final_state}") + logger.warning(f"No steps found in final_state to execute. final_state={final_state}") # Finally send the original RUN_FINISHED event if run_finished_event: - logger.info(">>> Yielding original RUN_FINISHED event") + logger.info("Yielding original RUN_FINISHED event") yield run_finished_event diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index f656e499797..cb32b73197f 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -2,6 +2,135 @@ The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python. +## Quick Start - Client Examples + +If you want to quickly try out the AG-UI client, we provide three ready-to-use examples: + +### Basic Interactive Client (`client.py`) + +A simple command-line chat client that demonstrates: +- Streaming responses in real-time +- Automatic thread management for conversation continuity +- Direct `AGUIChatClient` usage (caller manages message history) + +**Run:** +```bash +python client.py +``` + +**Note:** This example sends only the current message to the server. The server is responsible for maintaining conversation history using the thread_id. + +### Advanced Features Client (`client_advanced.py`) + +Demonstrates advanced capabilities: +- Tool/function calling +- Both streaming and non-streaming responses +- Multi-turn conversations +- Error handling patterns + +**Run:** +```bash +python client_advanced.py +``` + +**Note:** This example shows direct `AGUIChatClient` usage. Tool execution and conversation continuity depend on server-side configuration and capabilities. + +### ChatAgent Integration (`client_with_agent.py`) + +Best practice example using `ChatAgent` wrapper with **AgentThread** +- **AgentThread** maintains conversation state +- Client-side conversation history management via `thread.message_store` +- **Hybrid tool execution**: client-side + server-side tools simultaneously +- Full conversation history sent on each request +- Tool calling with conversation context + +**To demonstrate hybrid tools:** + +1. **Start server with server-side tool** (Terminal 1): + ```bash + # Server has get_time_zone tool + python server.py + ``` + +2. **Run client with client-side tool** (Terminal 2): + ```bash + # Client has get_weather tool + python client_with_agent.py + ``` + +All examples require a running AG-UI server (see Step 1 below for setup). + +## Understanding AG-UI Architecture + +### Thread Management + +The AG-UI protocol supports two approaches to conversation history: + +1. **Server-Managed Threads** (client.py, client_advanced.py) + - Client sends only the current message + thread_id + - Server maintains full conversation history + - Requires server to support stateful thread storage + - Lighter network payload + +2. **Client-Managed History** (client_with_agent.py) + - Client maintains full conversation history locally + - Full message history sent with each request + - Works with any AG-UI server (stateful or stateless) + +The `ChatAgent` wrapper (used in client_with_agent.py) collects messages from local storage and sends the full history to `AGUIChatClient`, which then forwards everything to the server. + +### Tool/Function Calling + +The AG-UI protocol supports **hybrid tool execution** - both client-side AND server-side tools can coexist in the same conversation. + +**The Hybrid Pattern** (client_with_agent.py): +``` +Client defines: Server defines: +- get_weather() - get_current_time() +- read_sensors() - get_server_forecast() + +User: "What's the weather in SF and what time is it?" + ↓ +ChatAgent sends: full history + tool definitions for get_weather, read_sensors + ↓ +Server LLM decides: "I need get_weather('SF') and get_current_time()" + ↓ +Server executes get_current_time() → "2025-11-11 14:30:00 UTC" +Server sends function call request → get_weather('SF') + ↓ +ChatAgent intercepts get_weather call → executes locally + ↓ +Client sends result → "Sunny, 72°F" + ↓ +Server combines both results → "It's sunny and 72°F in SF, and the current time is 2:30 PM UTC" + ↓ +Client receives final response +``` + +**How it works:** + +1. **Client-Side Tools** (`client_with_agent.py`): + - Tools defined in ChatAgent's `tools` parameter execute locally + - Tool metadata (name, description, schema) sent to server for planning + - When server requests client tool → client intercepts → executes locally → sends result + +2. **Server-Side Tools**: + - Defined in server agent's configuration + - Server executes directly without client involvement + - Results included in server's response + +3. **Hybrid Pattern (Both Together)**: + - Server LLM sees ALL tool definitions (client + server) + - Decides which to use based on task + - Server tools execute server-side + - Client tools execute client-side + +**Direct AGUIChatClient Usage** (client_advanced.py): +Even without ChatAgent wrapper, client-side tools work: +- Tools passed in ChatOptions execute locally +- Server can also have its own tools +- Hybrid execution works automatically + ## What is AG-UI? AG-UI is a protocol that enables: @@ -35,13 +164,13 @@ The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using Fas ### Install Required Packages ```bash -pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +pip install agent-framework-ag-ui ``` Or using uv: ```bash -uv pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +uv pip install agent-framework-ag-ui ``` ### Server Code @@ -57,17 +186,20 @@ import os from agent_framework import ChatAgent from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from fastapi import FastAPI # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") +api_key = os.environ.get("AZURE_OPENAI_API_KEY") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") if not deployment_name: raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") +if not api_key: + raise ValueError("AZURE_OPENAI_API_KEY environment variable is required") # Create the AI agent agent = ChatAgent( @@ -76,6 +208,7 @@ agent = ChatAgent( chat_client=AzureOpenAIChatClient( endpoint=endpoint, deployment_name=deployment_name, + api_key=api_key, ), ) @@ -137,12 +270,14 @@ The server will start listening on `http://127.0.0.1:5100`. ## Step 2: Creating an AG-UI Client -The AG-UI client connects to the remote server and displays streaming responses. +The AG-UI client connects to the remote server and displays streaming responses. The `AGUIChatClient` is a built-in implementation that integrates with the Agent Framework's standard chat interface. ### Install Required Packages +The `AGUIChatClient` is included in the `agent-framework-ag-ui` package (already installed if you installed the server packages). + ```bash -pip install httpx +pip install agent-framework-ag-ui ``` ### Client Code @@ -152,122 +287,61 @@ Create a file named `client.py`: ```python # Copyright (c) Microsoft. All rights reserved. -"""AG-UI client example.""" +"""AG-UI client example using AGUIChatClient.""" import asyncio -import json import os -from typing import AsyncIterator - -import httpx - - -class AGUIClient: - """Simple AG-UI protocol client.""" - - def __init__(self, server_url: str): - """Initialize the client. - - Args: - server_url: The AG-UI server endpoint URL - """ - self.server_url = server_url - self.thread_id: str | None = None - - async def send_message(self, message: str) -> AsyncIterator[dict]: - """Send a message and stream the response. - - Args: - message: The user message to send - - Yields: - AG-UI events from the server - """ - # Prepare the request - request_data = { - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": message}, - ] - } - - # Include thread_id if we have one (for conversation continuity) - if self.thread_id: - request_data["thread_id"] = self.thread_id - - # Stream the response - async with httpx.AsyncClient(timeout=60.0) as client: - async with client.stream( - "POST", - self.server_url, - json=request_data, - headers={"Accept": "text/event-stream"}, - ) as response: - response.raise_for_status() - - async for line in response.aiter_lines(): - # Parse Server-Sent Events format - if line.startswith("data: "): - data = line[6:] # Remove "data: " prefix - try: - event = json.loads(data) - yield event - - # Capture thread_id from RUN_STARTED event - if event.get("type") == "RUN_STARTED" and not self.thread_id: - self.thread_id = event.get("threadId") - except json.JSONDecodeError: - continue + +from agent_framework import TextContent +from agent_framework.ag_ui import AGUIChatClient async def main(): - """Main client loop.""" + """Main client loop demonstrating AGUIChatClient usage.""" # Get server URL from environment or use default server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") print(f"Connecting to AG-UI server at: {server_url}\n") - client = AGUIClient(server_url) + # Create client with context manager for automatic cleanup + async with AGUIChatClient(endpoint=server_url) as client: + thread_id: str | None = None - try: - while True: - # Get user input - message = input("\nUser (:q or quit to exit): ") - if not message.strip(): - print("Request cannot be empty.") - continue + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue - if message.lower() in (":q", "quit"): - break + if message.lower() in (":q", "quit"): + break - # Send message and display streaming response - print("\n", end="") - async for event in client.send_message(message): - event_type = event.get("type", "") + # Send message and stream the response + print("\nAssistant: ", end="", flush=True) - if event_type == "RUN_STARTED": - thread_id = event.get("threadId", "") - run_id = event.get("runId", "") - print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") + # Use metadata to maintain conversation continuity + metadata = {"thread_id": thread_id} if thread_id else None - elif event_type == "TEXT_MESSAGE_CONTENT": - # Stream text content in cyan - print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) + async for update in client.get_streaming_response(message, metadata=metadata): + # Extract thread ID from first update + if not thread_id and update.additional_properties: + thread_id = update.additional_properties.get("thread_id") + if thread_id: + print(f"\n[Thread: {thread_id}]") + print("Assistant: ", end="", flush=True) - elif event_type == "RUN_FINISHED": - thread_id = event.get("threadId", "") - run_id = event.get("runId", "") - print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") + # Stream text content as it arrives + for content in update.contents: + if isinstance(content, TextContent) and content.text: + print(content.text, end="", flush=True) - elif event_type == "RUN_ERROR": - error_message = event.get("message", "Unknown error") - print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") + print() # New line after response - print() - - except KeyboardInterrupt: - print("\n\nExiting...") - except Exception as e: - print(f"\n\033[91mAn error occurred: {e}\033[0m") + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\nAn error occurred: {e}") if __name__ == "__main__": @@ -276,17 +350,13 @@ if __name__ == "__main__": ### Key Concepts -- **Server-Sent Events (SSE)**: The protocol uses SSE format (`data: {json}\n\n`) -- **Event Types**: Different events provide metadata and content (all event types use UPPERCASE with underscores): - - `RUN_STARTED`: Signals the agent has started processing - - `TEXT_MESSAGE_START`: Signals the start of a text message from the agent - - `TEXT_MESSAGE_CONTENT`: Incremental text streamed from the agent (with `delta` field) - - `TEXT_MESSAGE_END`: Signals the end of a text message - - `RUN_FINISHED`: Signals successful completion - - `RUN_ERROR`: Error information if something goes wrong -- **Field Naming**: Event fields use camelCase (e.g., `threadId`, `runId`, `messageId`) when accessing JSON events -- **Thread Management**: The `threadId` maintains conversation context across requests -- **Client-Side Instructions**: System messages are sent from the client +- **`AGUIChatClient`**: Built-in client that implements the Agent Framework's `BaseChatClient` interface +- **Automatic Event Handling**: The client automatically converts AG-UI events to Agent Framework types +- **Thread Management**: Pass `thread_id` in metadata to maintain conversation context across requests +- **Streaming Responses**: Use `get_streaming_response()` for real-time streaming or `get_response()` for non-streaming +- **Context Manager**: Use `async with` for automatic cleanup of HTTP connections +- **Standard Interface**: Works with all Agent Framework patterns (ChatAgent, tools, etc.) +- **Hybrid Tool Execution**: Supports both client-side and server-side tools executing together in the same conversation ### Configure and Run the Client @@ -312,326 +382,12 @@ Connecting to AG-UI server at: http://127.0.0.1:5100/ User (:q or quit to exit): What is the capital of France? -[Run Started - Thread: abc123, Run: xyz789] -The capital of France is Paris. It is known for its rich history, culture, +[Thread: abc123] +Assistant: The capital of France is Paris. It is known for its rich history, culture, and iconic landmarks such as the Eiffel Tower and the Louvre Museum. -[Run Finished - Thread: abc123, Run: xyz789] User (:q or quit to exit): Tell me a fun fact about space - -[Run Started - Thread: abc123, Run: def456] -Here's a fun fact: A day on Venus is longer than its year! Venus takes -about 243 Earth days to rotate once on its axis, but only about 225 Earth -days to orbit the Sun. -[Run Finished - Thread: abc123, Run: def456] - -User (:q or quit to exit): :q -``` - -### Color-Coded Output - -The client displays different content types with distinct colors: -- **Yellow**: Run started notifications -- **Cyan**: Agent text responses (streamed in real-time) -- **Green**: Run completion notifications -- **Red**: Error messages - -## Testing with curl (Optional) - -Before running the client, you can test the server manually using curl: - -```bash -curl -N http://127.0.0.1:5100/ \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "messages": [ - {"role": "user", "content": "What is the capital of France?"} - ] - }' -``` - -You should see Server-Sent Events streaming back: - ``` -data: {"type":"RUN_STARTED","threadId":"...","runId":"..."} - -data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant"} - -data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"The"} - -data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" capital"} - -... - -data: {"type":"TEXT_MESSAGE_END","messageId":"..."} - -data: {"type":"RUN_FINISHED","threadId":"...","runId":"..."} -``` - -## How It Works - -### Server-Side Flow - -1. Client sends HTTP POST request with messages -2. FastAPI endpoint receives the request -3. `AgentFrameworkAgent` wrapper orchestrates the execution -4. Agent processes the messages using Agent Framework -5. `AgentFrameworkEventBridge` converts agent updates to AG-UI events -6. Responses are streamed back as Server-Sent Events (SSE) -7. Connection closes when the run completes - -### Client-Side Flow - -1. Client sends HTTP POST request to server endpoint -2. Server responds with SSE stream -3. Client parses incoming `data:` lines as JSON events -4. Each event is displayed based on its type -5. `threadId` is captured for conversation continuity -6. Stream completes when `RUN_FINISHED` event arrives - -### Protocol Details - -The AG-UI protocol uses: -- **HTTP POST** for sending requests -- **Server-Sent Events (SSE)** for streaming responses -- **JSON** for event serialization -- **Thread IDs** for maintaining conversation context -- **Run IDs** for tracking individual executions -- **Event type naming**: UPPERCASE with underscores (e.g., `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`) -- **Field naming**: camelCase (e.g., `threadId`, `runId`, `messageId`) - -## Advanced Features - -The Python AG-UI implementation supports all 7 AG-UI features: - -### 1. Backend Tool Rendering - -Add tools to your agent for backend execution: - -```python -from typing import Any - -from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient - - -@ai_function -def get_weather(location: str) -> dict[str, Any]: - """Get weather for a location.""" - return {"temperature": 72, "conditions": "sunny"} - - -agent = ChatAgent( - name="weather_agent", - instructions="Use tools to help users.", - chat_client=AzureOpenAIChatClient( - endpoint="https://your-resource.openai.azure.com/", - deployment_name="gpt-4o-mini", - ), - tools=[get_weather], -) -``` - -The client will receive `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, and `TOOL_CALL_RESULT` events. - -### 2. Human in the Loop - -Request user confirmation before executing tools: - -```python -from fastapi import FastAPI -from agent_framework import ChatAgent -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint - -agent = ChatAgent( - name="my_agent", - instructions="You are a helpful assistant.", - chat_client=AzureOpenAIChatClient( - endpoint="https://your-resource.openai.azure.com/", - deployment_name="gpt-4o-mini", - ), -) - -wrapped_agent = AgentFrameworkAgent( - agent=agent, - require_confirmation=True, # Enable human-in-the-loop -) - -app = FastAPI() -add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/") -``` - -The client receives tool approval request events and can send approval responses. - -### 3. State Management - -Share state between client and server: - -```python -wrapped_agent = AgentFrameworkAgent( - agent=agent, - state_schema={ - "location": {"type": "string"}, - "preferences": {"type": "object"}, - }, -) -``` - -Events include `STATE_SNAPSHOT` and `STATE_DELTA` for bidirectional sync. - -### 4. Predictive State Updates - -Stream tool arguments as optimistic state updates: - -```python -wrapped_agent = AgentFrameworkAgent( - agent=agent, - predict_state_config={ - "location": {"tool": "get_weather", "tool_argument": "location"} - }, - require_confirmation=False, # Auto-update without confirmation -) -``` - -State updates stream in real-time as the LLM generates tool arguments. - -## Common Patterns - -### Custom Server Configuration - -```python -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -app = FastAPI() - -# Add CORS for web clients -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -add_agent_framework_fastapi_endpoint(app, agent, "/agent") -``` - -### Multiple Agents - -```python -app = FastAPI() - -weather_agent = ChatAgent(name="weather", ...) -finance_agent = ChatAgent(name="finance", ...) - -add_agent_framework_fastapi_endpoint(app, weather_agent, "/weather") -add_agent_framework_fastapi_endpoint(app, finance_agent, "/finance") -``` - -### Custom Client Timeout - -```python -async with httpx.AsyncClient(timeout=300.0) as client: - async with client.stream("POST", server_url, ...) as response: - async for line in response.aiter_lines(): - # Process events - pass -``` - -### Error Handling - -```python -try: - async for event in client.send_message(message): - if event.get("type") == "RUN_ERROR": - error_msg = event.get("message", "Unknown error") - print(f"Error: {error_msg}") - # Handle error appropriately -except httpx.HTTPError as e: - print(f"HTTP error: {e}") -except Exception as e: - print(f"Unexpected error: {e}") -``` - -### Conversation Continuity - -The client automatically maintains `threadId` across requests: - -```python -client = AGUIClient(server_url) - -# First message -async for event in client.send_message("Hello"): - # Client captures threadId from RUN_STARTED - pass - -# Second message - uses same threadId -async for event in client.send_message("Continue our conversation"): - # Conversation context is maintained - pass -``` - -## AG-UI Event Reference - -### Core Events - -| Event Type | Description | Key Fields | -|------------|-------------|------------| -| `RUN_STARTED` | Agent execution started | `threadId`, `runId` | -| `RUN_FINISHED` | Agent execution completed | `threadId`, `runId` | -| `RUN_ERROR` | Agent execution error | `message` | - -### Text Message Events - -| Event Type | Description | Key Fields | -|------------|-------------|------------| -| `TEXT_MESSAGE_START` | Start of agent text message | `messageId`, `role` | -| `TEXT_MESSAGE_CONTENT` | Streaming text content | `messageId`, `delta` | -| `TEXT_MESSAGE_END` | End of agent text message | `messageId` | - -### Tool Events - -| Event Type | Description | Key Fields | -|------------|-------------|------------| -| `TOOL_CALL_START` | Tool call initiated | `toolCallId`, `toolCallName` | -| `TOOL_CALL_ARGS` | Tool arguments streaming | `toolCallId`, `delta` | -| `TOOL_CALL_END` | Tool call complete | `toolCallId` | -| `TOOL_CALL_RESULT` | Tool execution result | `toolCallId`, `content` | - -### State Events - -| Event Type | Description | Key Fields | -|------------|-------------|------------| -| `STATE_SNAPSHOT` | Complete state | `snapshot` | -| `STATE_DELTA` | State changes (JSON Patch) | `delta` | - -### Other Events - -| Event Type | Description | Key Fields | -|------------|-------------|------------| -| `MESSAGES_SNAPSHOT` | Conversation history | `messages` | -| `CUSTOM` | Custom event data | `name`, `value` | - -## Next Steps - -Now that you understand the basics of AG-UI, you can: - -- **Add Tools**: Create custom `@ai_function` tools for your domain -- **Web Integration**: Build React/Vue frontends using the AG-UI protocol -- **State Management**: Implement shared state for generative UI applications -- **Human-in-the-Loop**: Add approval workflows for sensitive operations -- **Deployment**: Deploy to Azure Container Apps or Azure App Service -- **Multi-Agent Systems**: Coordinate multiple specialized agents -- **Monitoring**: Add logging and OpenTelemetry for observability - -## Additional Resources - -- [AG-UI Examples](../agent_framework_ag_ui_examples/README.md): Complete working examples for all 7 features -- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents -- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation ## Troubleshooting diff --git a/python/packages/ag-ui/getting_started/client.py b/python/packages/ag-ui/getting_started/client.py index 82d3d1358e6..621d8536cd9 100644 --- a/python/packages/ag-ui/getting_started/client.py +++ b/python/packages/ag-ui/getting_started/client.py @@ -1,121 +1,71 @@ # Copyright (c) Microsoft. All rights reserved. -"""AG-UI client example.""" +"""AG-UI client example using AGUIChatClient. + +This example demonstrates how to use the AGUIChatClient to connect to +a remote AG-UI server and interact with it using the Agent Framework's +standard chat interface. +""" import asyncio -import json import os -from collections.abc import AsyncIterator - -import httpx - - -class AGUIClient: - """Simple AG-UI protocol client.""" - - def __init__(self, server_url: str): - """Initialize the client. - - Args: - server_url: The AG-UI server endpoint URL - """ - self.server_url = server_url - self.thread_id: str | None = None - - async def send_message(self, message: str) -> AsyncIterator[dict]: - """Send a message and stream the response. - - Args: - message: The user message to send - - Yields: - AG-UI events from the server - """ - # Prepare the request - request_data: dict[str, object] = { - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": message}, - ] - } - - # Include thread_id if we have one (for conversation continuity) - if self.thread_id: - request_data["thread_id"] = self.thread_id - - # Stream the response - async with httpx.AsyncClient(timeout=60.0) as client: - async with client.stream( - "POST", - self.server_url, - json=request_data, - headers={"Accept": "text/event-stream"}, - ) as response: - response.raise_for_status() - - async for line in response.aiter_lines(): - # Parse Server-Sent Events format - if line.startswith("data: "): - data = line[6:] # Remove "data: " prefix - try: - event = json.loads(data) - yield event - - # Capture thread_id from RUN_STARTED event - if event.get("type") == "RUN_STARTED" and not self.thread_id: - self.thread_id = event.get("threadId") - except json.JSONDecodeError: - continue + +from agent_framework_ag_ui import AGUIChatClient async def main(): - """Main client loop.""" + """Main client loop demonstrating AGUIChatClient usage.""" # Get server URL from environment or use default server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") print(f"Connecting to AG-UI server at: {server_url}\n") - - client = AGUIClient(server_url) - - try: - while True: - # Get user input - message = input("\nUser (:q or quit to exit): ") - if not message.strip(): - print("Request cannot be empty.") - continue - - if message.lower() in (":q", "quit"): - break - - # Send message and display streaming response - print("\n", end="") - async for event in client.send_message(message): - event_type = event.get("type", "") - - if event_type == "RUN_STARTED": - thread_id = event.get("threadId", "") - run_id = event.get("runId", "") - print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") - - elif event_type == "TEXT_MESSAGE_CONTENT": - # Stream text content in cyan - print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) - - elif event_type == "RUN_FINISHED": - thread_id = event.get("threadId", "") - run_id = event.get("runId", "") - print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") - - elif event_type == "RUN_ERROR": - error_message = event.get("message", "Unknown error") - print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") - - print() - - except KeyboardInterrupt: - print("\n\nExiting...") - except Exception as e: - print(f"\n\033[91mAn error occurred: {e}\033[0m") + print("Using AGUIChatClient with automatic thread management and Agent Framework integration.\n") + + # Create client with context manager for automatic cleanup + async with AGUIChatClient(endpoint=server_url) as client: + thread_id: str | None = None + + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue + + if message.lower() in (":q", "quit"): + break + + # Send message and stream the response + print("\nAssistant: ", end="", flush=True) + + # Use metadata to maintain conversation continuity + metadata = {"thread_id": thread_id} if thread_id else None + + async for update in client.get_streaming_response(message, metadata=metadata): + # Extract and display thread ID from first update + if not thread_id and update.additional_properties: + thread_id = update.additional_properties.get("thread_id") + if thread_id: + print(f"\n\033[93m[Thread: {thread_id}]\033[0m", end="", flush=True) + print("\nAssistant: ", end="", flush=True) + + # Display text content as it streams + from agent_framework import TextContent + + for content in update.contents: + if isinstance(content, TextContent) and content.text: + print(f"\033[96m{content.text}\033[0m", end="", flush=True) + + # Display finish reason if present + if update.finish_reason: + print(f"\n\033[92m[Finished: {update.finish_reason}]\033[0m", end="", flush=True) + + print() # New line after response + + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\n\033[91mAn error occurred: {e}\033[0m") if __name__ == "__main__": diff --git a/python/packages/ag-ui/getting_started/client_advanced.py b/python/packages/ag-ui/getting_started/client_advanced.py new file mode 100644 index 00000000000..cb45a0b8dac --- /dev/null +++ b/python/packages/ag-ui/getting_started/client_advanced.py @@ -0,0 +1,235 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Advanced AG-UI client example with tools and features. + +This example demonstrates advanced AGUIChatClient features including: +- Tool/function calling +- Non-streaming responses +- Multiple conversation turns +- Error handling +""" + +import asyncio +import os + +from agent_framework import ai_function + +from agent_framework_ag_ui import AGUIChatClient + + +@ai_function +def get_weather(location: str) -> str: + """Get the current weather for a location. + + Args: + location: The city or location name + """ + # Simulate weather lookup + weather_data = { + "seattle": "Rainy, 55°F", + "san francisco": "Foggy, 62°F", + "new york": "Sunny, 68°F", + "london": "Cloudy, 52°F", + } + return weather_data.get(location.lower(), f"Weather data not available for {location}") + + +@ai_function +def calculate(a: float, b: float, operation: str) -> str: + """Perform basic arithmetic operations. + + Args: + a: First number + b: Second number + operation: Operation to perform (add, subtract, multiply, divide) + """ + try: + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + result = a / b + else: + return f"Unsupported operation: {operation}" + return f"The result is: {result}" + except Exception as e: + return f"Error calculating: {e}" + + +async def streaming_example(client: AGUIChatClient, thread_id: str | None = None): + """Demonstrate streaming responses.""" + print("\n" + "=" * 60) + print("STREAMING EXAMPLE") + print("=" * 60) + + metadata = {"thread_id": thread_id} if thread_id else None + + print("\nUser: Tell me a short joke\n") + print("Assistant: ", end="", flush=True) + + async for update in client.get_streaming_response("Tell me a short joke", metadata=metadata): + if not thread_id and update.additional_properties: + thread_id = update.additional_properties.get("thread_id") + + from agent_framework import TextContent + + for content in update.contents: + if isinstance(content, TextContent) and content.text: + print(content.text, end="", flush=True) + + print("\n") + return thread_id + + +async def non_streaming_example(client: AGUIChatClient, thread_id: str | None = None): + """Demonstrate non-streaming responses.""" + print("\n" + "=" * 60) + print("NON-STREAMING EXAMPLE") + print("=" * 60) + + metadata = {"thread_id": thread_id} if thread_id else None + + print("\nUser: What is 2 + 2?\n") + + response = await client.get_response("What is 2 + 2?", metadata=metadata) + + print(f"Assistant: {response.text}") + + if response.additional_properties: + thread_id = response.additional_properties.get("thread_id") + print(f"\n[Thread: {thread_id}]") + + return thread_id + + +async def tool_example(client: AGUIChatClient, thread_id: str | None = None): + """Demonstrate sending tool definitions to the server. + + IMPORTANT: When using AGUIChatClient directly (without ChatAgent wrapper): + - Tools are sent as DEFINITIONS only + - No automatic client-side execution (no function invocation middleware) + - Server must have matching tool implementations to execute them + + For CLIENT-SIDE tool execution (like .NET AGUIClient sample): + - Use ChatAgent wrapper with tools + - See client_with_agent.py for the hybrid pattern + - ChatAgent middleware intercepts and executes client tools locally + - Server can have its own tools that execute server-side + - Both client and server tools work together in same conversation + + This example sends tool definitions and assumes server-side execution. + """ + print("\n" + "=" * 60) + print("TOOL DEFINITION EXAMPLE") + print("=" * 60) + + metadata = {"thread_id": thread_id} if thread_id else None + + print("\nUser: What's the weather in Seattle?\n") + print("Sending tool definitions to server...") + print("(Server must be configured with matching tools to execute them)\n") + + response = await client.get_response( + "What's the weather in Seattle?", tools=[get_weather, calculate], metadata=metadata + ) + + print(f"Assistant: {response.text}") + + # Show tool calls if any + from agent_framework import FunctionCallContent + + tool_called = False + for message in response.messages: + for content in message.contents: + if isinstance(content, FunctionCallContent): + print(f"\n[Tool Called: {content.name}]") + tool_called = True + + if not tool_called: + print("\n[Note: No tools were called - server may not be configured for tool execution]") + + if response.additional_properties: + thread_id = response.additional_properties.get("thread_id") + + return thread_id + + +async def conversation_example(client: AGUIChatClient): + """Demonstrate multi-turn conversation. + + Note: Conversation continuity depends on the server maintaining thread state. + Some servers may require explicit message history to be sent with each request. + """ + print("\n" + "=" * 60) + print("MULTI-TURN CONVERSATION EXAMPLE") + print("=" * 60) + print("\nNote: This example uses thread_id for context. Server must support thread-based state.\n") + + # First turn + print("User: My name is Alice\n") + response1 = await client.get_response("My name is Alice") + print(f"Assistant: {response1.text}") + thread_id = response1.additional_properties.get("thread_id") + print(f"\n[Thread: {thread_id}]") + + # Second turn - using same thread + print("\nUser: What's my name?\n") + response2 = await client.get_response("What's my name?", metadata={"thread_id": thread_id}) + print(f"Assistant: {response2.text}") + + # Check if context was maintained + if "alice" not in response2.text.lower(): + print("\n[Note: Server may not maintain thread context - consider using ChatAgent for history management]") + + # Third turn + print("\nUser: Can you also tell me what 10 * 5 is?\n") + response3 = await client.get_response( + "Can you also tell me what 10 * 5 is?", metadata={"thread_id": thread_id}, tools=[calculate] + ) + print(f"Assistant: {response3.text}") + + +async def main(): + """Run all examples.""" + # Get server URL from environment or use default + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + + print("=" * 60) + print("AG-UI Chat Client Advanced Examples") + print("=" * 60) + print(f"\nServer: {server_url}") + print("\nThese examples demonstrate various AGUIChatClient features:") + print(" 1. Streaming responses") + print(" 2. Non-streaming responses") + print(" 3. Tool/function calling") + print(" 4. Multi-turn conversations") + + try: + async with AGUIChatClient(endpoint=server_url) as client: + # Run examples in sequence + thread_id = await streaming_example(client) + thread_id = await non_streaming_example(client, thread_id) + await tool_example(client, thread_id) + + # Separate conversation with new thread + await conversation_example(client) + + print("\n" + "=" * 60) + print("All examples completed successfully!") + print("=" * 60) + + except ConnectionError as e: + print(f"\n\033[91mConnection Error: {e}\033[0m") + print("\nMake sure an AG-UI server is running at the specified endpoint.") + except Exception as e: + print(f"\n\033[91mError: {e}\033[0m") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/ag-ui/getting_started/client_with_agent.py b/python/packages/ag-ui/getting_started/client_with_agent.py new file mode 100644 index 00000000000..ac69189b53b --- /dev/null +++ b/python/packages/ag-ui/getting_started/client_with_agent.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example showing ChatAgent with AGUIChatClient for hybrid tool execution. + +This demonstrates the HYBRID pattern matching .NET AGUIClient implementation: + +1. AgentThread Pattern (like .NET): + - Create thread with agent.get_new_thread() + - Pass thread to agent.run_stream() on each turn + - Thread automatically maintains conversation history via message_store + +2. Hybrid Tool Execution: + - AGUIChatClient has @use_function_invocation decorator + - Client-side tools (get_weather) can execute locally when server requests them + - Server may also have its own tools that execute server-side + - Both work together: server LLM decides which tool to call, decorator handles client execution + +This matches .NET pattern: thread maintains state, tools execute on appropriate side. +""" + +import asyncio +import logging +import os + +from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function + +from agent_framework_ag_ui import AGUIChatClient + +# Enable debug logging +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@ai_function(description="Get the current weather for a location.") +def get_weather(location: str) -> str: + """Get the current weather for a location. + + Args: + location: The city or location name + """ + print(f"[CLIENT] get_weather tool called with location: {location}") + weather_data = { + "seattle": "Rainy, 55°F", + "san francisco": "Foggy, 62°F", + "new york": "Sunny, 68°F", + "london": "Cloudy, 52°F", + } + result = weather_data.get(location.lower(), f"Weather data not available for {location}") + print(f"[CLIENT] get_weather returning: {result}") + return result + + +async def main(): + """Demonstrate ChatAgent + AGUIChatClient hybrid tool execution. + + This matches the .NET pattern from Program.cs where: + - AIAgent agent = chatClient.CreateAIAgent(tools: [...]) + - AgentThread thread = agent.GetNewThread() + - RunStreamingAsync(messages, thread) + + Python equivalent: + - agent = ChatAgent(chat_client=AGUIChatClient(...), tools=[...]) + - thread = agent.get_new_thread() # Creates thread with message_store + - agent.run_stream(message, thread=thread) # Thread accumulates history + """ + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + + print("=" * 70) + print("ChatAgent + AGUIChatClient: Hybrid Tool Execution") + print("=" * 70) + print(f"\nServer: {server_url}") + print("\nThis example demonstrates:") + print(" 1. AgentThread maintains conversation state (like .NET)") + print(" 2. Client-side tools execute locally via @use_function_invocation") + print(" 3. Server may have additional tools that execute server-side") + print(" 4. HYBRID: Client and server tools work together simultaneously\n") + + try: + # Create remote client in async context manager + async with AGUIChatClient(endpoint=server_url) as remote_client: + # Wrap in ChatAgent for conversation history management + agent = ChatAgent( + name="remote_assistant", + instructions="You are a helpful assistant. Remember user information across the conversation.", + chat_client=remote_client, + tools=[get_weather], + ) + + # Create a thread to maintain conversation state (like .NET AgentThread) + thread = agent.get_new_thread() + + print("=" * 70) + print("CONVERSATION WITH HISTORY") + print("=" * 70) + + # Turn 1: Introduce + print("\nUser: My name is Alice and I live in Seattle\n") + async for chunk in agent.run_stream("My name is Alice and I live in Seattle", thread=thread): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + # Turn 2: Ask about name (tests history) + print("User: What's my name?\n") + async for chunk in agent.run_stream("What's my name?", thread=thread): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + # Turn 3: Ask about location (tests history) + print("User: Where do I live?\n") + async for chunk in agent.run_stream("Where do I live?", thread=thread): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + # Turn 4: Test client-side tool (get_weather is client-side) + print("User: What's the weather forecast for today in Seattle?\n") + async for chunk in agent.run_stream("What's the weather forecast for today in Seattle?", thread=thread): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + # Turn 5: Test server-side tool (get_time_zone is server-side only) + print("User: What time zone is Seattle in?\n") + async for chunk in agent.run_stream("What time zone is Seattle in?", thread=thread): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + # Show thread state + if thread.message_store: + + def _preview_for_message(m) -> str: + # Prefer plain text when present + if getattr(m, "text", ""): + t = m.text + return (t[:60] + "...") if len(t) > 60 else t + # Build from contents when no direct text + parts: list[str] = [] + for c in getattr(m, "contents", []) or []: + if isinstance(c, FunctionCallContent): + args = c.arguments + if isinstance(args, dict): + try: + import json as _json + + args_str = _json.dumps(args) + except Exception: + args_str = str(args) + else: + args_str = str(args or "{}") + parts.append(f"tool_call {c.name} {args_str}") + elif isinstance(c, FunctionResultContent): + parts.append(f"tool_result[{c.call_id}]: {str(c.result)[:40]}") + elif isinstance(c, TextContent): + if c.text: + parts.append(c.text) + else: + typename = getattr(c, "type", c.__class__.__name__) + parts.append(f"<{typename}>") + preview = " | ".join(parts) if parts else "" + return (preview[:60] + "...") if len(preview) > 60 else preview + + messages = await thread.message_store.list_messages() + print(f"\n[THREAD STATE] {len(messages)} messages in thread's message_store") + for i, msg in enumerate(messages[-6:], 1): # Show last 6 + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + text_preview = _preview_for_message(msg) + print(f" {i}. [{role}]: {text_preview}") + + except ConnectionError as e: + print(f"\n\033[91mConnection Error: {e}\033[0m") + print("\nMake sure an AG-UI server is running at the specified endpoint.") + except Exception as e: + print(f"\n\033[91mError: {e}\033[0m") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py index 34e2edbd5f8..e4ed669516d 100644 --- a/python/packages/ag-ui/getting_started/server.py +++ b/python/packages/ag-ui/getting_started/server.py @@ -1,18 +1,26 @@ # Copyright (c) Microsoft. All rights reserved. -"""AG-UI server example.""" +"""AG-UI server example with server-side tools.""" +import logging import os -from agent_framework import ChatAgent +from agent_framework import ChatAgent, ai_function +from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework.azure import AzureOpenAIChatClient from dotenv import load_dotenv from fastapi import FastAPI -from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint - load_dotenv() +# Enable debug logging +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") @@ -22,14 +30,43 @@ if not deployment_name: raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required") -# Create the AI agent + +# Server-side tool (executes on server) +@ai_function(description="Get the time zone for a location.") +def get_time_zone(location: str) -> str: + """Get the time zone for a location. + + Args: + location: The city or location name + """ + print(f"[SERVER] get_time_zone tool called with location: {location}") + timezone_data = { + "seattle": "Pacific Time (UTC-8)", + "san francisco": "Pacific Time (UTC-8)", + "new york": "Eastern Time (UTC-5)", + "london": "Greenwich Mean Time (UTC+0)", + } + result = timezone_data.get(location.lower(), f"Time zone data not available for {location}") + print(f"[SERVER] get_time_zone returning: {result}") + return result + + +# Create the AI agent with ONLY server-side tools +# IMPORTANT: Do NOT include tools that the client provides! +# In this example: +# - get_time_zone: SERVER-ONLY tool (only server has this) +# - get_weather: CLIENT-ONLY tool (client provides this, server should NOT include it) +# The client will send get_weather tool metadata so the LLM knows about it, +# and @use_function_invocation on AGUIChatClient will execute it client-side. +# This matches the .NET AG-UI hybrid execution pattern. agent = ChatAgent( name="AGUIAssistant", - instructions="You are a helpful assistant.", + instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.", chat_client=AzureOpenAIChatClient( endpoint=endpoint, deployment_name=deployment_name, ), + tools=[get_time_zone], # ONLY server-side tools ) # Create FastAPI app @@ -41,4 +78,4 @@ if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=5100) + uvicorn.run(app, host="127.0.0.1", port=5100, log_level="debug", access_log=True) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 7c17e7502ce..9216a17e242 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b251108" +version = "1.0.0b251111" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/ag-ui/tests/test_client.py b/python/packages/ag-ui/tests/test_client.py new file mode 100644 index 00000000000..cfececd771f --- /dev/null +++ b/python/packages/ag-ui/tests/test_client.py @@ -0,0 +1,317 @@ +"""Tests for AGUIChatClient.""" + +import json + +from agent_framework import ChatMessage, ChatOptions, FunctionCallContent, Role, ai_function + +from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent + + +class TestAGUIChatClient: + """Test suite for AGUIChatClient.""" + + async def test_client_initialization(self) -> None: + """Test client initialization.""" + client = AGUIChatClient(endpoint="http://localhost:8888/") + + assert client._http_service is not None + assert client._http_service.endpoint.startswith("http://localhost:8888") + + async def test_client_context_manager(self) -> None: + """Test client as async context manager.""" + async with AGUIChatClient(endpoint="http://localhost:8888/") as client: + assert client is not None + + async def test_extract_state_from_messages_no_state(self) -> None: + """Test state extraction when no state is present.""" + client = AGUIChatClient(endpoint="http://localhost:8888/") + messages = [ + ChatMessage(role="user", text="Hello"), + ChatMessage(role="assistant", text="Hi there"), + ] + + result_messages, state = client._extract_state_from_messages(messages) + + assert result_messages == messages + assert state is None + + async def test_extract_state_from_messages_with_state(self) -> None: + """Test state extraction from last message.""" + import base64 + + client = AGUIChatClient(endpoint="http://localhost:8888/") + + state_data = {"key": "value", "count": 42} + state_json = json.dumps(state_data) + state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") + + from agent_framework import DataContent + + messages = [ + ChatMessage(role="user", text="Hello"), + ChatMessage( + role="user", + contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")], + ), + ] + + result_messages, state = client._extract_state_from_messages(messages) + + assert len(result_messages) == 1 + assert result_messages[0].text == "Hello" + assert state == state_data + + async def test_extract_state_invalid_json(self) -> None: + """Test state extraction with invalid JSON.""" + import base64 + + client = AGUIChatClient(endpoint="http://localhost:8888/") + + invalid_json = "not valid json" + state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8") + + from agent_framework import DataContent + + messages = [ + ChatMessage( + role="user", + contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")], + ), + ] + + result_messages, state = client._extract_state_from_messages(messages) + + assert result_messages == messages + assert state is None + + async def test_convert_messages_to_agui_format(self) -> None: + """Test message conversion to AG-UI format.""" + client = AGUIChatClient(endpoint="http://localhost:8888/") + messages = [ + ChatMessage(role=Role.USER, text="What is the weather?"), + ChatMessage(role=Role.ASSISTANT, text="Let me check.", message_id="msg_123"), + ] + + agui_messages = client._convert_messages_to_agui_format(messages) + + assert len(agui_messages) == 2 + assert agui_messages[0]["role"] == "user" + assert agui_messages[0]["content"] == "What is the weather?" + assert agui_messages[1]["role"] == "assistant" + assert agui_messages[1]["content"] == "Let me check." + assert agui_messages[1]["id"] == "msg_123" + + async def test_get_thread_id_from_metadata(self) -> None: + """Test thread ID extraction from metadata.""" + client = AGUIChatClient(endpoint="http://localhost:8888/") + chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"}) + + thread_id = client._get_thread_id(chat_options) + + assert thread_id == "existing_thread_123" + + async def test_get_thread_id_generation(self) -> None: + """Test automatic thread ID generation.""" + client = AGUIChatClient(endpoint="http://localhost:8888/") + chat_options = ChatOptions() + + thread_id = client._get_thread_id(chat_options) + + assert thread_id.startswith("thread_") + assert len(thread_id) > 7 + + async def test_get_streaming_response(self, monkeypatch) -> None: + """Test streaming response method.""" + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + for event in mock_events: + yield event + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + messages = [ChatMessage(role="user", text="Test message")] + chat_options = ChatOptions() + + updates = [] + async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): + updates.append(update) + + assert len(updates) == 4 + assert updates[0].additional_properties["thread_id"] == "thread_1" + assert updates[1].contents[0].text == "Hello" + assert updates[2].contents[0].text == " world" + + async def test_get_response_non_streaming(self, monkeypatch) -> None: + """Test non-streaming response method.""" + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Complete response"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + for event in mock_events: + yield event + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + messages = [ChatMessage(role="user", text="Test message")] + chat_options = ChatOptions() + + response = await client._inner_get_response(messages=messages, chat_options=chat_options) + + assert response is not None + assert len(response.messages) > 0 + assert "Complete response" in response.text + + async def test_tool_handling(self, monkeypatch) -> None: + """Test that client tool metadata is sent to server. + + Client tool metadata (name, description, schema) is sent to server for planning. + When server requests a client function, @use_function_invocation decorator + intercepts and executes it locally. This matches .NET AG-UI implementation. + """ + from agent_framework import ai_function + + @ai_function + def test_tool(param: str) -> str: + """Test tool.""" + return "result" + + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + # Client tool metadata should be sent to server + tools = kwargs.get("tools") + assert tools is not None + assert len(tools) == 1 + assert tools[0]["name"] == "test_tool" + assert tools[0]["description"] == "Test tool." + assert "parameters" in tools[0] + for event in mock_events: + yield event + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + messages = [ChatMessage(role="user", text="Test with tools")] + chat_options = ChatOptions(tools=[test_tool]) + + response = await client._inner_get_response(messages=messages, chat_options=chat_options) + + assert response is not None + + async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch) -> None: + """Ensure server-side tool calls are exposed as FunctionCallContent after processing.""" + + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"}, + {"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + for event in mock_events: + yield event + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + messages = [ChatMessage(role="user", text="Test server tool execution")] + chat_options = ChatOptions() + + updates = [] + async for update in client.get_streaming_response(messages, chat_options=chat_options): + updates.append(update) + + function_calls = [ + content for update in updates for content in update.contents if isinstance(content, FunctionCallContent) + ] + assert function_calls + assert function_calls[0].name == "get_time_zone" + assert not any( + isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents + ) + + async def test_server_tool_calls_not_executed_locally(self, monkeypatch) -> None: + """Server tools should not trigger local function invocation even when client tools exist.""" + + @ai_function + def client_tool() -> str: + """Client tool stub.""" + return "client" + + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"}, + {"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + for event in mock_events: + yield event + + async def fake_auto_invoke(*args, **kwargs): + function_call = kwargs.get("function_call_content") or args[0] + raise AssertionError(f"Unexpected local execution of server tool: {getattr(function_call, 'name', '?')}") + + monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke) + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + messages = [ChatMessage(role="user", text="Test server tool execution")] + chat_options = ChatOptions(tool_choice="auto", tools=[client_tool]) + + async for _ in client.get_streaming_response(messages, chat_options=chat_options): + pass + + async def test_state_transmission(self, monkeypatch) -> None: + """Test state is properly transmitted to server.""" + import base64 + + state_data = {"user_id": "123", "session": "abc"} + state_json = json.dumps(state_data) + state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") + + from agent_framework import DataContent + + messages = [ + ChatMessage(role="user", text="Hello"), + ChatMessage( + role="user", + contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")], + ), + ] + + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args, **kwargs): + assert kwargs.get("state") == state_data + for event in mock_events: + yield event + + client = AGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client._http_service, "post_run", mock_post_run) + + chat_options = ChatOptions() + + response = await client._inner_get_response(messages=messages, chat_options=chat_options) + + assert response is not None diff --git a/python/packages/ag-ui/tests/test_event_converters.py b/python/packages/ag-ui/tests/test_event_converters.py new file mode 100644 index 00000000000..d05b1fe7203 --- /dev/null +++ b/python/packages/ag-ui/tests/test_event_converters.py @@ -0,0 +1,287 @@ +"""Tests for AG-UI event converter.""" + +from agent_framework import FinishReason, Role + +from agent_framework_ag_ui._event_converters import AGUIEventConverter + + +class TestAGUIEventConverter: + """Test suite for AGUIEventConverter.""" + + def test_run_started_event(self) -> None: + """Test conversion of RUN_STARTED event.""" + converter = AGUIEventConverter() + event = { + "type": "RUN_STARTED", + "threadId": "thread_123", + "runId": "run_456", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert update.additional_properties["thread_id"] == "thread_123" + assert update.additional_properties["run_id"] == "run_456" + assert converter.thread_id == "thread_123" + assert converter.run_id == "run_456" + + def test_text_message_start_event(self) -> None: + """Test conversion of TEXT_MESSAGE_START event.""" + converter = AGUIEventConverter() + event = { + "type": "TEXT_MESSAGE_START", + "messageId": "msg_789", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert update.message_id == "msg_789" + assert converter.current_message_id == "msg_789" + + def test_text_message_content_event(self) -> None: + """Test conversion of TEXT_MESSAGE_CONTENT event.""" + converter = AGUIEventConverter() + event = { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": "msg_1", + "delta": "Hello", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert update.message_id == "msg_1" + assert len(update.contents) == 1 + assert update.contents[0].text == "Hello" + + def test_text_message_streaming(self) -> None: + """Test streaming text across multiple TEXT_MESSAGE_CONTENT events.""" + converter = AGUIEventConverter() + events = [ + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "!"}, + ] + + updates = [converter.convert_event(event) for event in events] + + assert all(update is not None for update in updates) + assert all(update.message_id == "msg_1" for update in updates) + assert updates[0].contents[0].text == "Hello" + assert updates[1].contents[0].text == " world" + assert updates[2].contents[0].text == "!" + + def test_text_message_end_event(self) -> None: + """Test conversion of TEXT_MESSAGE_END event.""" + converter = AGUIEventConverter() + event = { + "type": "TEXT_MESSAGE_END", + "messageId": "msg_1", + } + + update = converter.convert_event(event) + + assert update is None + + def test_tool_call_start_event(self) -> None: + """Test conversion of TOOL_CALL_START event.""" + converter = AGUIEventConverter() + event = { + "type": "TOOL_CALL_START", + "toolCallId": "call_123", + "toolName": "get_weather", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert len(update.contents) == 1 + assert update.contents[0].call_id == "call_123" + assert update.contents[0].name == "get_weather" + assert update.contents[0].arguments == "" + assert converter.current_tool_call_id == "call_123" + assert converter.current_tool_name == "get_weather" + + def test_tool_call_start_with_tool_call_name(self) -> None: + """Ensure TOOL_CALL_START with toolCallName still sets the tool name.""" + converter = AGUIEventConverter() + event = { + "type": "TOOL_CALL_START", + "toolCallId": "call_abc", + "toolCallName": "get_weather", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.contents[0].name == "get_weather" + assert converter.current_tool_name == "get_weather" + + def test_tool_call_start_with_tool_call_name_snake_case(self) -> None: + """Support tool_call_name snake_case field for backwards compatibility.""" + converter = AGUIEventConverter() + event = { + "type": "TOOL_CALL_START", + "toolCallId": "call_snake", + "tool_call_name": "get_weather", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.contents[0].name == "get_weather" + assert converter.current_tool_name == "get_weather" + + def test_tool_call_args_streaming(self) -> None: + """Test streaming tool arguments across multiple TOOL_CALL_ARGS events.""" + converter = AGUIEventConverter() + converter.current_tool_call_id = "call_123" + converter.current_tool_name = "search" + + events = [ + {"type": "TOOL_CALL_ARGS", "delta": '{"query": "'}, + {"type": "TOOL_CALL_ARGS", "delta": 'latest news"}'}, + ] + + updates = [converter.convert_event(event) for event in events] + + assert all(update is not None for update in updates) + assert updates[0].contents[0].arguments == '{"query": "' + assert updates[1].contents[0].arguments == 'latest news"}' + assert converter.accumulated_tool_args == '{"query": "latest news"}' + + def test_tool_call_end_event(self) -> None: + """Test conversion of TOOL_CALL_END event.""" + converter = AGUIEventConverter() + converter.accumulated_tool_args = '{"location": "Seattle"}' + + event = { + "type": "TOOL_CALL_END", + "toolCallId": "call_123", + } + + update = converter.convert_event(event) + + assert update is None + assert converter.accumulated_tool_args == "" + + def test_tool_call_result_event(self) -> None: + """Test conversion of TOOL_CALL_RESULT event.""" + converter = AGUIEventConverter() + event = { + "type": "TOOL_CALL_RESULT", + "toolCallId": "call_123", + "result": {"temperature": 22, "condition": "sunny"}, + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.TOOL + assert len(update.contents) == 1 + assert update.contents[0].call_id == "call_123" + assert update.contents[0].result == {"temperature": 22, "condition": "sunny"} + + def test_run_finished_event(self) -> None: + """Test conversion of RUN_FINISHED event.""" + converter = AGUIEventConverter() + converter.thread_id = "thread_123" + converter.run_id = "run_456" + + event = { + "type": "RUN_FINISHED", + "threadId": "thread_123", + "runId": "run_456", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert update.finish_reason == FinishReason.STOP + assert update.additional_properties["thread_id"] == "thread_123" + assert update.additional_properties["run_id"] == "run_456" + + def test_run_error_event(self) -> None: + """Test conversion of RUN_ERROR event.""" + converter = AGUIEventConverter() + converter.thread_id = "thread_123" + converter.run_id = "run_456" + + event = { + "type": "RUN_ERROR", + "message": "Connection timeout", + } + + update = converter.convert_event(event) + + assert update is not None + assert update.role == Role.ASSISTANT + assert update.finish_reason == FinishReason.CONTENT_FILTER + assert len(update.contents) == 1 + assert update.contents[0].message == "Connection timeout" + assert update.contents[0].error_code == "RUN_ERROR" + + def test_unknown_event_type(self) -> None: + """Test handling of unknown event types.""" + converter = AGUIEventConverter() + event = { + "type": "UNKNOWN_EVENT", + "data": "some data", + } + + update = converter.convert_event(event) + + assert update is None + + def test_full_conversation_flow(self) -> None: + """Test complete conversation flow with multiple event types.""" + converter = AGUIEventConverter() + + events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "TEXT_MESSAGE_START", "messageId": "msg_1"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "I'll check"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " the weather."}, + {"type": "TEXT_MESSAGE_END", "messageId": "msg_1"}, + {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_weather"}, + {"type": "TOOL_CALL_ARGS", "delta": '{"location": "Seattle"}'}, + {"type": "TOOL_CALL_END", "toolCallId": "call_1"}, + {"type": "TOOL_CALL_RESULT", "toolCallId": "call_1", "result": "Sunny, 72°F"}, + {"type": "TEXT_MESSAGE_START", "messageId": "msg_2"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_2", "delta": "It's sunny!"}, + {"type": "TEXT_MESSAGE_END", "messageId": "msg_2"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + updates = [converter.convert_event(event) for event in events] + non_none_updates = [u for u in updates if u is not None] + + assert len(non_none_updates) == 10 + assert converter.thread_id == "thread_1" + assert converter.run_id == "run_1" + + def test_multiple_tool_calls(self) -> None: + """Test handling multiple tool calls in sequence.""" + converter = AGUIEventConverter() + + events = [ + {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "search"}, + {"type": "TOOL_CALL_ARGS", "delta": '{"query": "weather"}'}, + {"type": "TOOL_CALL_END", "toolCallId": "call_1"}, + {"type": "TOOL_CALL_START", "toolCallId": "call_2", "toolName": "fetch"}, + {"type": "TOOL_CALL_ARGS", "delta": '{"url": "http://api.weather.com"}'}, + {"type": "TOOL_CALL_END", "toolCallId": "call_2"}, + ] + + updates = [converter.convert_event(event) for event in events] + non_none_updates = [u for u in updates if u is not None] + + assert len(non_none_updates) == 4 + assert non_none_updates[0].contents[0].name == "search" + assert non_none_updates[2].contents[0].name == "fetch" diff --git a/python/packages/ag-ui/tests/test_http_service.py b/python/packages/ag-ui/tests/test_http_service.py new file mode 100644 index 00000000000..641ae4f88b8 --- /dev/null +++ b/python/packages/ag-ui/tests/test_http_service.py @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for AGUIHttpService.""" + +import json +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from agent_framework_ag_ui._http_service import AGUIHttpService + + +@pytest.fixture +def mock_http_client(): + """Create a mock httpx.AsyncClient.""" + client = AsyncMock(spec=httpx.AsyncClient) + return client + + +@pytest.fixture +def sample_events(): + """Sample AG-UI events for testing.""" + return [ + {"type": "RUN_STARTED", "threadId": "thread_123", "runId": "run_456"}, + {"type": "TEXT_MESSAGE_START", "messageId": "msg_1", "role": "assistant"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"}, + {"type": "TEXT_MESSAGE_END", "messageId": "msg_1"}, + {"type": "RUN_FINISHED", "threadId": "thread_123", "runId": "run_456"}, + ] + + +def create_sse_response(events: list[dict]) -> str: + """Create SSE formatted response from events.""" + lines = [] + for event in events: + lines.append(f"data: {json.dumps(event)}\n") + return "\n".join(lines) + + +async def test_http_service_initialization(): + """Test AGUIHttpService initialization.""" + # Test with default client + service = AGUIHttpService("http://localhost:8888/") + assert service.endpoint == "http://localhost:8888" + assert service._owns_client is True + assert isinstance(service.http_client, httpx.AsyncClient) + await service.close() + + # Test with custom client + custom_client = httpx.AsyncClient() + service = AGUIHttpService("http://localhost:8888/", http_client=custom_client) + assert service._owns_client is False + assert service.http_client is custom_client + # Shouldn't close the custom client + await service.close() + await custom_client.aclose() + + +async def test_http_service_strips_trailing_slash(): + """Test that endpoint trailing slash is stripped.""" + service = AGUIHttpService("http://localhost:8888/") + assert service.endpoint == "http://localhost:8888" + await service.close() + + +async def test_post_run_successful_streaming(mock_http_client, sample_events): + """Test successful streaming of events.""" + + # Create async generator for lines + async def mock_aiter_lines(): + sse_data = create_sse_response(sample_events) + for line in sse_data.split("\n"): + if line: + yield line + + # Create mock response + mock_response = AsyncMock() + mock_response.status_code = 200 + # aiter_lines is called as a method, so it should return a new generator each time + mock_response.aiter_lines = mock_aiter_lines + + # Setup mock streaming context manager + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + events = [] + async for event in service.post_run( + thread_id="thread_123", run_id="run_456", messages=[{"role": "user", "content": "Hello"}] + ): + events.append(event) + + assert len(events) == len(sample_events) + assert events[0]["type"] == "RUN_STARTED" + assert events[-1]["type"] == "RUN_FINISHED" + + # Verify request was made correctly + mock_http_client.stream.assert_called_once() + call_args = mock_http_client.stream.call_args + assert call_args.args[0] == "POST" + assert call_args.args[1] == "http://localhost:8888" + assert call_args.kwargs["headers"] == {"Accept": "text/event-stream"} + + +async def test_post_run_with_state_and_tools(mock_http_client): + """Test posting run with state and tools.""" + + async def mock_aiter_lines(): + return + yield # Make it an async generator + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + state = {"user_context": {"name": "Alice"}} + tools = [{"type": "function", "function": {"name": "test_tool"}}] + + async for _ in service.post_run(thread_id="thread_123", run_id="run_456", messages=[], state=state, tools=tools): + pass + + # Verify state and tools were included in request + call_args = mock_http_client.stream.call_args + request_data = call_args.kwargs["json"] + assert request_data["state"] == state + assert request_data["tools"] == tools + + +async def test_post_run_http_error(mock_http_client): + """Test handling of HTTP errors.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + def raise_http_error(): + raise httpx.HTTPStatusError("Server error", request=Mock(), response=mock_response) + + mock_response_async = AsyncMock() + mock_response_async.raise_for_status = raise_http_error + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response_async + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + with pytest.raises(httpx.HTTPStatusError): + async for _ in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]): + pass + + +async def test_post_run_invalid_json(mock_http_client): + """Test handling of invalid JSON in SSE stream.""" + invalid_sse = "data: {invalid json}\n\ndata: " + json.dumps({"type": "RUN_FINISHED"}) + "\n" + + async def mock_aiter_lines(): + for line in invalid_sse.split("\n"): + if line: + yield line + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + events = [] + async for event in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]): + events.append(event) + + # Should skip invalid JSON and continue with valid events + assert len(events) == 1 + assert events[0]["type"] == "RUN_FINISHED" + + +async def test_context_manager(): + """Test context manager functionality.""" + async with AGUIHttpService("http://localhost:8888/") as service: + assert service.http_client is not None + assert service._owns_client is True + + # Client should be closed after exiting context + + +async def test_context_manager_with_external_client(): + """Test context manager doesn't close external client.""" + external_client = httpx.AsyncClient() + + async with AGUIHttpService("http://localhost:8888/", http_client=external_client) as service: + assert service.http_client is external_client + assert service._owns_client is False + + # External client should still be open + # (caller's responsibility to close) + await external_client.aclose() + + +async def test_post_run_empty_response(mock_http_client): + """Test handling of empty response stream.""" + + async def mock_aiter_lines(): + return + yield # Make it an async generator + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + events = [] + async for event in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]): + events.append(event) + + assert len(events) == 0 diff --git a/python/packages/ag-ui/tests/test_message_adapters.py b/python/packages/ag-ui/tests/test_message_adapters.py index 1a5bb0ccd76..a21375b87b2 100644 --- a/python/packages/ag-ui/tests/test_message_adapters.py +++ b/python/packages/ag-ui/tests/test_message_adapters.py @@ -63,10 +63,9 @@ def test_agui_tool_result_to_agent_framework(): assert isinstance(message.contents[0], TextContent) assert message.contents[0].text == '{"accepted": true, "steps": []}' - assert hasattr(message, "metadata") - assert message.metadata is not None - assert message.metadata.get("is_tool_result") is True - assert message.metadata.get("tool_call_id") == "call_123" + assert message.additional_properties is not None + assert message.additional_properties.get("is_tool_result") is True + assert message.additional_properties.get("tool_call_id") == "call_123" def test_agui_multiple_messages_to_agent_framework(): @@ -159,6 +158,36 @@ def test_agui_message_without_id(): assert messages[0].message_id is None +def test_agui_with_tool_calls_to_agent_framework(): + """Assistant message with tool_calls is converted to FunctionCallContent.""" + agui_msg = { + "role": "assistant", + "content": "Calling tool", + "tool_calls": [ + { + "id": "call-123", + "type": "function", + "function": {"name": "get_weather", "arguments": {"location": "Seattle"}}, + } + ], + "id": "msg-789", + } + + messages = agui_messages_to_agent_framework([agui_msg]) + + assert len(messages) == 1 + msg = messages[0] + assert msg.role == Role.ASSISTANT + assert msg.message_id == "msg-789" + # First content is text, second is the function call + assert isinstance(msg.contents[0], TextContent) + assert msg.contents[0].text == "Calling tool" + assert isinstance(msg.contents[1], FunctionCallContent) + assert msg.contents[1].call_id == "call-123" + assert msg.contents[1].name == "get_weather" + assert msg.contents[1].arguments == {"location": "Seattle"} + + def test_agent_framework_to_agui_with_tool_calls(): """Test converting Agent Framework message with tool calls to AG-UI.""" msg = ChatMessage( @@ -198,13 +227,15 @@ def test_agent_framework_to_agui_multiple_text_contents(): def test_agent_framework_to_agui_no_message_id(): - """Test message without message_id.""" + """Test message without message_id - should auto-generate ID.""" msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]) messages = agent_framework_messages_to_agui([msg]) assert len(messages) == 1 - assert "id" not in messages[0] + assert "id" in messages[0] # ID should be auto-generated + assert messages[0]["id"] # ID should not be empty + assert len(messages[0]["id"]) > 0 # ID should be a valid string def test_agent_framework_to_agui_system_role(): diff --git a/python/packages/ag-ui/tests/test_orchestrators.py b/python/packages/ag-ui/tests/test_orchestrators.py new file mode 100644 index 00000000000..a400e784587 --- /dev/null +++ b/python/packages/ag-ui/tests/test_orchestrators.py @@ -0,0 +1,82 @@ +"""Tests for AG-UI orchestrators.""" + +from collections.abc import AsyncGenerator +from types import SimpleNamespace +from typing import Any + +from agent_framework import AgentRunResponseUpdate, TextContent, ai_function +from agent_framework._tools import FunctionInvocationConfiguration + +from agent_framework_ag_ui._agent import AgentConfig +from agent_framework_ag_ui._orchestrators import DefaultOrchestrator, ExecutionContext + + +@ai_function +def server_tool() -> str: + """Server-executable tool.""" + return "server" + + +class DummyAgent: + """Minimal agent stub to capture run_stream parameters.""" + + def __init__(self) -> None: + self.chat_options = SimpleNamespace(tools=[server_tool], response_format=None) + self.tools = [server_tool] + self.chat_client = SimpleNamespace( + function_invocation_configuration=FunctionInvocationConfiguration(), + ) + self.seen_tools: list[Any] | None = None + + async def run_stream( + self, + messages: list[Any], + *, + thread: Any, + tools: list[Any] | None = None, + ) -> AsyncGenerator[AgentRunResponseUpdate, None]: + self.seen_tools = tools + yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant") + + +async def test_default_orchestrator_merges_client_tools() -> None: + """Client tool declarations are merged with server tools before running agent.""" + + agent = DummyAgent() + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}], + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Client weather lookup.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + ], + } + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + assert agent.seen_tools is not None + tool_names = [getattr(tool, "name", "?") for tool in agent.seen_tools] + assert "server_tool" in tool_names + assert "get_weather" in tool_names + assert agent.chat_client.function_invocation_configuration.additional_tools diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py index 9bc477310c9..e4324ab187d 100644 --- a/python/packages/ag-ui/tests/test_utils.py +++ b/python/packages/ag-ui/tests/test_utils.py @@ -197,3 +197,109 @@ def test_make_json_safe_fallback(): result = make_json_safe(obj) # Objects with __dict__ return their __dict__ dict assert isinstance(result, dict) + + +def test_convert_tools_to_agui_format_with_ai_function(): + """Test converting AIFunction to AG-UI format.""" + from agent_framework import ai_function + + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + @ai_function + def test_func(param: str, count: int = 5) -> str: + """Test function.""" + return f"{param} {count}" + + result = convert_tools_to_agui_format([test_func]) + + assert result is not None + assert len(result) == 1 + assert result[0]["name"] == "test_func" + assert result[0]["description"] == "Test function." + assert "parameters" in result[0] + assert "properties" in result[0]["parameters"] + + +def test_convert_tools_to_agui_format_with_callable(): + """Test converting plain callable to AG-UI format.""" + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + def plain_func(x: int) -> int: + """A plain function.""" + return x * 2 + + result = convert_tools_to_agui_format([plain_func]) + + assert result is not None + assert len(result) == 1 + assert result[0]["name"] == "plain_func" + assert result[0]["description"] == "A plain function." + assert "parameters" in result[0] + + +def test_convert_tools_to_agui_format_with_dict(): + """Test converting dict tool to AG-UI format.""" + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + tool_dict = { + "name": "custom_tool", + "description": "Custom tool", + "parameters": {"type": "object"}, + } + + result = convert_tools_to_agui_format([tool_dict]) + + assert result is not None + assert len(result) == 1 + assert result[0] == tool_dict + + +def test_convert_tools_to_agui_format_with_none(): + """Test converting None tools.""" + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + result = convert_tools_to_agui_format(None) + + assert result is None + + +def test_convert_tools_to_agui_format_with_single_tool(): + """Test converting single tool (not in list).""" + from agent_framework import ai_function + + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + @ai_function + def single_tool(arg: str) -> str: + """Single tool.""" + return arg + + result = convert_tools_to_agui_format(single_tool) + + assert result is not None + assert len(result) == 1 + assert result[0]["name"] == "single_tool" + + +def test_convert_tools_to_agui_format_with_multiple_tools(): + """Test converting multiple tools.""" + from agent_framework import ai_function + + from agent_framework_ag_ui._utils import convert_tools_to_agui_format + + @ai_function + def tool1(x: int) -> int: + """Tool 1.""" + return x + + @ai_function + def tool2(y: str) -> str: + """Tool 2.""" + return y + + result = convert_tools_to_agui_format([tool1, tool2]) + + assert result is not None + assert len(result) == 2 + assert result[0]["name"] == "tool1" + assert result[1]["name"] == "tool2" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 4941d0a2215..cacd760e764 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 4c4ce24fdbe..fa15e4c0745 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index a5057247a80..8c0a5047e44 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index cad44cd5161..9872355b4ec 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 0125adb188d..e3ea1bdea67 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -587,9 +587,11 @@ def __init__( name: str | None = None, description: str | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - conversation_id: str | None = None, context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None, middleware: Middleware | list[Middleware] | None = None, + # chat option params + allow_multiple_tool_calls: bool | None = None, + conversation_id: str | None = None, frequency_penalty: float | None = None, logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, @@ -630,15 +632,17 @@ def __init__( description: A brief description of the agent's purpose. chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol. If not provided, the default in-memory store will be used. - conversation_id: The conversation ID for service-managed threads. - Cannot be used together with chat_message_store_factory. context_providers: The collection of multiple context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response. + conversation_id: The conversation ID for service-managed threads. + Cannot be used together with chat_message_store_factory. frequency_penalty: The frequency penalty to use. logit_bias: The logit bias to use. max_tokens: The maximum number of tokens to generate. metadata: Additional metadata to include in the request. model_id: The model_id to use for the agent. + This overrides the model_id set in the chat client if it contains one. presence_penalty: The presence penalty to use. response_format: The format of the response. seed: The random seed to use. @@ -687,7 +691,8 @@ def __init__( self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)] self.chat_options = ChatOptions( - model_id=model_id, + model_id=model_id or (str(chat_client.model_id) if hasattr(chat_client, "model_id") else None), + allow_multiple_tool_calls=allow_multiple_tool_calls, conversation_id=conversation_id, frequency_penalty=frequency_penalty, instructions=instructions, @@ -758,6 +763,7 @@ async def run( messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, *, thread: AgentThread | None = None, + allow_multiple_tool_calls: bool | None = None, frequency_penalty: float | None = None, logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, @@ -793,6 +799,7 @@ async def run( Keyword Args: thread: The thread to use for the agent. + allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response. frequency_penalty: The frequency penalty to use. logit_bias: The logit bias to use. max_tokens: The maximum number of tokens to generate. @@ -844,6 +851,7 @@ async def run( co = run_chat_options & ChatOptions( model_id=model_id, conversation_id=thread.service_thread_id, + allow_multiple_tool_calls=allow_multiple_tool_calls, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, @@ -887,6 +895,7 @@ async def run_stream( messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, *, thread: AgentThread | None = None, + allow_multiple_tool_calls: bool | None = None, frequency_penalty: float | None = None, logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, @@ -922,6 +931,7 @@ async def run_stream( Keyword Args: thread: The thread to use for the agent. + allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response. frequency_penalty: The frequency penalty to use. logit_bias: The logit bias to use. max_tokens: The maximum number of tokens to generate. @@ -971,6 +981,7 @@ async def run_stream( co = run_chat_options & ChatOptions( conversation_id=thread.service_thread_id, + allow_multiple_tool_calls=allow_multiple_tool_calls, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 3cac845ed32..116148b80f5 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -224,7 +224,7 @@ def _merge_chat_options( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] | None = None, top_p: float | None = None, user: str | None = None, @@ -496,7 +496,7 @@ async def get_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -595,7 +595,7 @@ async def get_streaming_response( stop: str | Sequence[str] | None = None, store: bool | None = None, temperature: float | None = None, - tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto", + tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, tools: ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] @@ -722,6 +722,8 @@ def create_agent( chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None, middleware: Middleware | list[Middleware] | None = None, + allow_multiple_tool_calls: bool | None = None, + conversation_id: str | None = None, frequency_penalty: float | None = None, logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, @@ -759,6 +761,8 @@ def create_agent( If not provided, the default in-memory store will be used. context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. + allow_multiple_tool_calls: Whether to allow multiple tool calls per agent turn. + conversation_id: The conversation ID to associate with the agent's messages. frequency_penalty: The frequency penalty to use. logit_bias: The logit bias to use. max_tokens: The maximum number of tokens to generate. @@ -809,6 +813,8 @@ def create_agent( chat_message_store_factory=chat_message_store_factory, context_providers=context_providers, middleware=middleware, + allow_multiple_tool_calls=allow_multiple_tool_calls, + conversation_id=conversation_id, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 50f6a91c2e6..873b7f04ccf 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -19,7 +19,7 @@ from mcp.shared.context import RequestContext from mcp.shared.exceptions import McpError from mcp.shared.session import RequestResponder -from pydantic import BaseModel, create_model +from pydantic import BaseModel, Field, create_model from ._tools import AIFunction, HostedMCPSpecificApproval from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent @@ -224,13 +224,20 @@ def resolve_type(prop_details: dict[str, Any]) -> type: prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details python_type = resolve_type(prop_details) + description = prop_details.get("description", "") # Create field definition for create_model if prop_name in required: - field_definitions[prop_name] = (python_type, ...) + field_definitions[prop_name] = ( + (python_type, Field(description=description)) if description else (python_type, ...) + ) else: default_value = prop_details.get("default", None) - field_definitions[prop_name] = (python_type, default_value) + field_definitions[prop_name] = ( + (python_type, Field(default=default_value, description=description)) + if description + else (python_type, default_value) + ) return create_model(f"{tool.name}_input", **field_definitions) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 83df62e29dd..117c9efe525 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1525,6 +1525,12 @@ async def function_invocation_wrapper( prepped_messages = prepare_messages(messages) response: "ChatResponse | None" = None fcc_messages: "list[ChatMessage]" = [] + + # If tools are provided but tool_choice is not set, default to "auto" for function invocation + tools = _extract_tools(kwargs) + if tools and kwargs.get("tool_choice") is None: + kwargs["tool_choice"] = "auto" + for attempt_idx in range(config.max_iterations if config.enabled else 0): fcc_todo = _collect_approval_responses(prepped_messages) if fcc_todo: diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 9f2ad10d859..8dc7c006559 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1050,6 +1050,50 @@ def _validate_uri(cls, uri: str) -> str: def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool: return _has_top_level_media_type(self.media_type, top_level_media_type) + @staticmethod + def detect_image_format_from_base64(image_base64: str) -> str: + """Detect image format from base64 data by examining the binary header. + + Args: + image_base64: Base64 encoded image data + + Returns: + Image format as string (png, jpeg, webp, gif) with png as fallback + """ + try: + # Constants for image format detection + # ~75 bytes of binary data should be enough to detect most image formats + FORMAT_DETECTION_BASE64_CHARS = 100 + + # Decode a small portion to detect format + decoded_data = base64.b64decode(image_base64[:FORMAT_DETECTION_BASE64_CHARS]) + if decoded_data.startswith(b"\x89PNG"): + return "png" + if decoded_data.startswith(b"\xff\xd8\xff"): + return "jpeg" + if decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]: + return "webp" + if decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"): + return "gif" + return "png" # Default fallback + except Exception: + return "png" # Fallback if decoding fails + + @classmethod + def create_data_uri_from_base64(cls, image_base64: str) -> tuple[str, str]: + """Create a data URI and media type from base64 image data. + + Args: + image_base64: Base64 encoded image data + + Returns: + Tuple of (data_uri, media_type) + """ + format_type = cls.detect_image_format_from_base64(image_base64) + uri = f"data:image/{format_type};base64,{image_base64}" + media_type = f"image/{format_type}" + return uri, media_type + class UriContent(BaseContent): """Represents a URI content. diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 0c05abbb690..8fa85b7f849 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -2,11 +2,14 @@ import logging from dataclasses import dataclass -from typing import Any +from typing import Any, cast + +from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResponseContent from .._agents import AgentProtocol, ChatAgent from .._threads import AgentThread from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage +from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from ._conversation_state import encode_chat_messages from ._events import ( AgentRunEvent, @@ -14,6 +17,7 @@ ) from ._executor import Executor, handler from ._message_utils import normalize_messages_input +from ._request_info_mixin import response_handler from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -83,6 +87,8 @@ def __init__( super().__init__(exec_id) self._agent = agent self._agent_thread = agent_thread or self._agent.get_new_thread() + self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {} + self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = [] self._output_response = output_response self._cache: list[ChatMessage] = [] @@ -93,50 +99,6 @@ def workflow_output_types(self) -> list[type[Any]]: return [AgentRunResponse] return [] - async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None: - """Execute the underlying agent, emit events, and enqueue response. - - Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent - events (streaming mode) or a single AgentRunEvent (non-streaming mode). - """ - if ctx.is_streaming(): - # Streaming mode: emit incremental updates - updates: list[AgentRunResponseUpdate] = [] - async for update in self._agent.run_stream( - self._cache, - thread=self._agent_thread, - ): - updates.append(update) - await ctx.add_event(AgentRunUpdateEvent(self.id, update)) - - if isinstance(self._agent, ChatAgent): - response_format = self._agent.chat_options.response_format - response = AgentRunResponse.from_agent_run_response_updates( - updates, - output_format_type=response_format, - ) - else: - response = AgentRunResponse.from_agent_run_response_updates(updates) - else: - # Non-streaming mode: use run() and emit single event - response = await self._agent.run( - self._cache, - thread=self._agent_thread, - ) - await ctx.add_event(AgentRunEvent(self.id, response)) - - if self._output_response: - await ctx.yield_output(response) - - # Always construct a full conversation snapshot from inputs (cache) - # plus agent outputs (agent_run_response.messages). Do not mutate - # response.messages so AgentRunEvent remains faithful to the raw output. - full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages) - - agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation) - await ctx.send_message(agent_response) - self._cache.clear() - @handler async def run( self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse] @@ -192,6 +154,31 @@ async def from_messages( self._cache = normalize_messages_input(messages) await self._run_agent_and_emit(ctx) + @response_handler + async def handle_user_input_response( + self, + original_request: FunctionApprovalRequestContent, + response: FunctionApprovalResponseContent, + ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse], + ) -> None: + """Handle user input responses for function approvals during agent execution. + + This will hold the executor's execution until all pending user input requests are resolved. + + Args: + original_request: The original function approval request sent by the agent. + response: The user's response to the function approval request. + ctx: The workflow context for emitting events and outputs. + """ + self._pending_responses_to_agent.append(response) + self._pending_agent_requests.pop(original_request.id, None) + + if not self._pending_agent_requests: + # All pending requests have been resolved; resume agent execution + self._cache = normalize_messages_input(ChatMessage(role="user", contents=self._pending_responses_to_agent)) + self._pending_responses_to_agent.clear() + await self._run_agent_and_emit(ctx) + async def snapshot_state(self) -> dict[str, Any]: """Capture current executor state for checkpointing. @@ -226,6 +213,8 @@ async def snapshot_state(self) -> dict[str, Any]: return { "cache": encode_chat_messages(self._cache), "agent_thread": serialized_thread, + "pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests), + "pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent), } async def restore_state(self, state: dict[str, Any]) -> None: @@ -258,7 +247,109 @@ async def restore_state(self, state: dict[str, Any]) -> None: else: self._agent_thread = self._agent.get_new_thread() + pending_requests_payload = state.get("pending_agent_requests") + if pending_requests_payload: + self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload) + + pending_responses_payload = state.get("pending_responses_to_agent") + if pending_responses_payload: + self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload) + def reset(self) -> None: """Reset the internal cache of the executor.""" logger.debug("AgentExecutor %s: Resetting cache", self.id) self._cache.clear() + + async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None: + """Execute the underlying agent, emit events, and enqueue response. + + Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent + events (streaming mode) or a single AgentRunEvent (non-streaming mode). + """ + if ctx.is_streaming(): + # Streaming mode: emit incremental updates + response = await self._run_agent_streaming(cast(WorkflowContext, ctx)) + else: + # Non-streaming mode: use run() and emit single event + response = await self._run_agent(cast(WorkflowContext, ctx)) + + if response is None: + # Agent did not complete (e.g., waiting for user input); do not emit response + logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id) + return + + if self._output_response: + await ctx.yield_output(response) + + # Always construct a full conversation snapshot from inputs (cache) + # plus agent outputs (agent_run_response.messages). Do not mutate + # response.messages so AgentRunEvent remains faithful to the raw output. + full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages) + + agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation) + await ctx.send_message(agent_response) + self._cache.clear() + + async def _run_agent(self, ctx: WorkflowContext) -> AgentRunResponse | None: + """Execute the underlying agent in non-streaming mode. + + Args: + ctx: The workflow context for emitting events. + + Returns: + The complete AgentRunResponse, or None if waiting for user input. + """ + response = await self._agent.run( + self._cache, + thread=self._agent_thread, + ) + await ctx.add_event(AgentRunEvent(self.id, response)) + + # Handle any user input requests + if response.user_input_requests: + for user_input_request in response.user_input_requests: + self._pending_agent_requests[user_input_request.id] = user_input_request + await ctx.request_info(user_input_request, FunctionApprovalResponseContent) + return None + + return response + + async def _run_agent_streaming(self, ctx: WorkflowContext) -> AgentRunResponse | None: + """Execute the underlying agent in streaming mode and collect the full response. + + Args: + ctx: The workflow context for emitting events. + + Returns: + The complete AgentRunResponse, or None if waiting for user input. + """ + updates: list[AgentRunResponseUpdate] = [] + user_input_requests: list[FunctionApprovalRequestContent] = [] + async for update in self._agent.run_stream( + self._cache, + thread=self._agent_thread, + ): + updates.append(update) + await ctx.add_event(AgentRunUpdateEvent(self.id, update)) + + if update.user_input_requests: + user_input_requests.extend(update.user_input_requests) + + # Build the final AgentRunResponse from the collected updates + if isinstance(self._agent, ChatAgent): + response_format = self._agent.chat_options.response_format + response = AgentRunResponse.from_agent_run_response_updates( + updates, + output_format_type=response_format, + ) + else: + response = AgentRunResponse.from_agent_run_response_updates(updates) + + # Handle any user input requests after the streaming completes + if user_input_requests: + for user_input_request in user_input_requests: + self._pending_agent_requests[user_input_request.id] = user_input_request + await ctx.request_info(user_input_request, FunctionApprovalResponseContent) + return None + + return response diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 3c5995aeafc..c29e3f55adf 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -85,8 +85,8 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: # so we need to recombine them here to pass the complete tools list to the constructor. # This makes sure MCP tools are preserved when cloning agents for handoff workflows. all_tools = list(options.tools) if options.tools else [] - if agent._local_mcp_tools: - all_tools.extend(agent._local_mcp_tools) + if agent._local_mcp_tools: # type: ignore + all_tools.extend(agent._local_mcp_tools) # type: ignore return ChatAgent( chat_client=agent.chat_client, @@ -133,6 +133,14 @@ class _ConversationWithUserInput: full_conversation: list[ChatMessage] = field(default_factory=lambda: []) # type: ignore[misc] +@dataclass +class _ConversationForUserInput: + """Internal message from coordinator to gateway specifying which agent will receive the response.""" + + conversation: list[ChatMessage] + next_agent_id: str + + class _AutoHandoffMiddleware(FunctionMiddleware): """Intercept handoff tool invocations and short-circuit execution with synthetic results.""" @@ -275,6 +283,7 @@ def __init__( termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]], id: str, handoff_tool_targets: Mapping[str, str] | None = None, + return_to_previous: bool = False, ) -> None: """Create a coordinator that manages routing between specialists and the user.""" super().__init__(id) @@ -284,6 +293,8 @@ def __init__( self._input_gateway_id = input_gateway_id self._termination_condition = termination_condition self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()} + self._return_to_previous = return_to_previous + self._current_agent_id: str | None = None # Track the current agent handling conversation def _get_author_name(self) -> str: """Get the coordinator name for orchestrator-generated messages.""" @@ -293,7 +304,7 @@ def _get_author_name(self) -> str: async def handle_agent_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage]], + ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage] | _ConversationForUserInput], ) -> None: """Process an agent's response and determine whether to route, request input, or terminate.""" # Hydrate coordinator state (and detect new run) using checkpointable executor state @@ -329,6 +340,9 @@ async def handle_agent_response( # Check for handoff from ANY agent (starting agent or specialist) target = self._resolve_specialist(response.agent_run_response, conversation) if target is not None: + # Update current agent when handoff occurs + self._current_agent_id = target + logger.info(f"Handoff detected: {source} -> {target}. Routing control to specialist '{target}'.") await self._persist_state(ctx) # Clean tool-related content before sending to next agent cleaned = clean_conversation_for_handoff(conversation) @@ -340,10 +354,15 @@ async def handle_agent_response( if not is_starting_agent and source not in self._specialist_ids: raise RuntimeError(f"HandoffCoordinator received response from unknown executor '{source}'.") + # Update current agent when they respond without handoff + self._current_agent_id = source + logger.info( + f"Agent '{source}' responded without handoff. " + f"Requesting user input. Return-to-previous: {self._return_to_previous}" + ) await self._persist_state(ctx) if await self._check_termination(): - logger.info("Handoff workflow termination condition met. Ending conversation.") # Clean the output conversation for display cleaned_output = clean_conversation_for_handoff(conversation) await ctx.yield_output(cleaned_output) @@ -352,7 +371,13 @@ async def handle_agent_response( # Clean conversation before sending to gateway for user input request # This removes tool messages that shouldn't be shown to users cleaned_for_display = clean_conversation_for_handoff(conversation) - await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id) + + # The awaiting_agent_id is the agent that just responded and is awaiting user input + # This is the source of the current response + next_agent_id = source + + message_to_gateway = _ConversationForUserInput(conversation=cleaned_for_display, next_agent_id=next_agent_id) + await ctx.send_message(message_to_gateway, target_id=self._input_gateway_id) # type: ignore[arg-type] @handler async def handle_user_input( @@ -367,14 +392,26 @@ async def handle_user_input( # Check termination before sending to agent if await self._check_termination(): - logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(self._conversation)) return - # Clean before sending to starting agent + # Determine routing target based on return-to-previous setting + target_agent_id = self._starting_agent_id + if self._return_to_previous and self._current_agent_id: + # Route back to the current agent that's handling the conversation + target_agent_id = self._current_agent_id + logger.info( + f"Return-to-previous enabled: routing user input to current agent '{target_agent_id}' " + f"(bypassing coordinator '{self._starting_agent_id}')" + ) + else: + logger.info(f"Routing user input to coordinator '{target_agent_id}'") + # Note: Stack is only used for specialist-to-specialist handoffs, not user input routing + + # Clean before sending to target agent cleaned = clean_conversation_for_handoff(self._conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) - await ctx.send_message(request, target_id=self._starting_agent_id) + await ctx.send_message(request, target_id=target_agent_id) def _resolve_specialist(self, agent_response: AgentRunResponse, conversation: list[ChatMessage]) -> str | None: """Resolve the specialist executor id requested by the agent response, if any.""" @@ -444,22 +481,27 @@ async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: def _snapshot_pattern_metadata(self) -> dict[str, Any]: """Serialize pattern-specific state. - Handoff has no additional metadata beyond base conversation state. + Includes the current agent for return-to-previous routing. Returns: - Empty dict (no pattern-specific state) + Dict containing current agent if return-to-previous is enabled """ + if self._return_to_previous: + return { + "current_agent_id": self._current_agent_id, + } return {} def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None: """Restore pattern-specific state. - Handoff has no additional metadata beyond base conversation state. + Restores the current agent for return-to-previous routing. Args: - metadata: Pattern-specific state dict (ignored) + metadata: Pattern-specific state dict """ - pass + if self._return_to_previous and "current_agent_id" in metadata: + self._current_agent_id = metadata["current_agent_id"] def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]: """Rehydrate the coordinator's conversation history from checkpointed state. @@ -507,8 +549,21 @@ def __init__( self._prompt = prompt or "Provide your next input for the conversation." @handler - async def request_input(self, conversation: list[ChatMessage], ctx: WorkflowContext) -> None: + async def request_input(self, message: _ConversationForUserInput, ctx: WorkflowContext) -> None: """Emit a `HandoffUserInputRequest` capturing the conversation snapshot.""" + if not message.conversation: + raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.") + request = HandoffUserInputRequest( + conversation=list(message.conversation), + awaiting_agent_id=message.next_agent_id, + prompt=self._prompt, + source_executor_id=self.id, + ) + await ctx.request_info(request, object) + + @handler + async def request_input_legacy(self, conversation: list[ChatMessage], ctx: WorkflowContext) -> None: + """Legacy handler for backward compatibility - emit user input request with starting agent.""" if not conversation: raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.") request = HandoffUserInputRequest( @@ -558,7 +613,7 @@ def _as_user_messages(payload: Any) -> list[ChatMessage]: def _default_termination_condition(conversation: list[ChatMessage]) -> bool: - """Default termination: stop after 10 user messages to prevent infinite loops.""" + """Default termination: stop after 10 user messages.""" user_message_count = sum(1 for msg in conversation if msg.role == Role.USER) return user_message_count >= 10 @@ -743,6 +798,7 @@ def __init__( ) self._auto_register_handoff_tools: bool = True self._handoff_config: dict[str, list[str]] = {} # Maps agent_id -> [target_agent_ids] + self._return_to_previous: bool = False if participants: self.participants(participants) @@ -1198,6 +1254,77 @@ async def check_termination(conv: list[ChatMessage]) -> bool: self._termination_condition = condition return self + def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder": + """Enable direct return to the current agent after user input, bypassing the coordinator. + + When enabled, after a specialist responds without requesting another handoff, user input + routes directly back to that same specialist instead of always routing back to the + coordinator agent for re-evaluation. + + This is useful when a specialist needs multiple turns with the user to gather information + or resolve an issue, avoiding unnecessary coordinator involvement while maintaining context. + + Flow Comparison: + + **Default (disabled):** + User -> Coordinator -> Specialist -> User -> Coordinator -> Specialist -> ... + + **With return_to_previous (enabled):** + User -> Coordinator -> Specialist -> User -> Specialist -> ... + + Args: + enabled: Whether to enable return-to-previous routing. Default is True. + + Returns: + Self for method chaining. + + Example: + + .. code-block:: python + + workflow = ( + HandoffBuilder(participants=[triage, technical_support, billing]) + .set_coordinator("triage") + .add_handoff(triage, [technical_support, billing]) + .enable_return_to_previous() # Enable direct return routing + .build() + ) + + # Flow: User asks question + # -> Triage routes to Technical Support + # -> Technical Support asks clarifying question + # -> User provides more info + # -> Routes back to Technical Support (not Triage) + # -> Technical Support continues helping + + Multi-tier handoff example: + + .. code-block:: python + + workflow = ( + HandoffBuilder(participants=[triage, specialist_a, specialist_b]) + .set_coordinator("triage") + .add_handoff(triage, [specialist_a, specialist_b]) + .add_handoff(specialist_a, specialist_b) + .enable_return_to_previous() + .build() + ) + + # Flow: User asks question + # -> Triage routes to Specialist A + # -> Specialist A hands off to Specialist B + # -> Specialist B asks clarifying question + # -> User provides more info + # -> Routes back to Specialist B (who is currently handling the conversation) + + Note: + This feature routes to whichever agent most recently responded, whether that's + the coordinator or a specialist. The conversation continues with that agent until + they either hand off to another agent or the termination condition is met. + """ + self._return_to_previous = enabled + return self + def build(self) -> Workflow: """Construct the final Workflow instance from the configured builder. @@ -1326,6 +1453,7 @@ def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: termination_condition=self._termination_condition, id="handoff-coordinator", handoff_tool_targets=handoff_tool_targets, + return_to_previous=self._return_to_previous, ) wiring = _GroupChatConfig( diff --git a/python/packages/core/agent_framework/ag_ui/__init__.py b/python/packages/core/agent_framework/ag_ui/__init__.py new file mode 100644 index 00000000000..c5569ed7a92 --- /dev/null +++ b/python/packages/core/agent_framework/ag_ui/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +from typing import Any + +PACKAGE_NAME = "agent_framework_ag_ui" +PACKAGE_EXTRA = "ag-ui" +_IMPORTS = [ + "__version__", + "AgentFrameworkAgent", + "add_agent_framework_fastapi_endpoint", + "AGUIChatClient", + "AGUIEventConverter", + "AGUIHttpService", + "ConfirmationStrategy", + "DefaultConfirmationStrategy", + "TaskPlannerConfirmationStrategy", + "RecipeConfirmationStrategy", + "DocumentWriterConfirmationStrategy", +] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(PACKAGE_NAME), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + ) from exc + raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/ag_ui/__init__.pyi b/python/packages/core/agent_framework/ag_ui/__init__.pyi new file mode 100644 index 00000000000..201e1a02560 --- /dev/null +++ b/python/packages/core/agent_framework/ag_ui/__init__.pyi @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_ag_ui import ( + AgentFrameworkAgent, + AGUIChatClient, + AGUIEventConverter, + AGUIHttpService, + ConfirmationStrategy, + DefaultConfirmationStrategy, + DocumentWriterConfirmationStrategy, + RecipeConfirmationStrategy, + TaskPlannerConfirmationStrategy, + __version__, + add_agent_framework_fastapi_endpoint, +) + +__all__ = [ + "AGUIChatClient", + "AGUIEventConverter", + "AGUIHttpService", + "AgentFrameworkAgent", + "ConfirmationStrategy", + "DefaultConfirmationStrategy", + "DocumentWriterConfirmationStrategy", + "RecipeConfirmationStrategy", + "TaskPlannerConfirmationStrategy", + "__version__", + "add_agent_framework_fastapi_endpoint", +] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index c17ce12666d..3e44fae23c0 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -846,6 +846,7 @@ async def trace_get_response( kwargs.get("model_id") or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None) or getattr(self, "model_id", None) + or "unknown" ) service_url = str( service_url_func() @@ -933,6 +934,7 @@ async def trace_get_streaming_response( kwargs.get("model_id") or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None) or getattr(self, "model_id", None) + or "unknown" ) service_url = str( service_url_func() @@ -1324,7 +1326,10 @@ def _get_span( attributes: dict[str, Any], span_name_attribute: str, ) -> Generator["trace.Span", Any, Any]: - """Start a span for a agent run.""" + """Start a span for a agent run. + + Note: `attributes` must contain the `span_name_attribute` key. + """ span = get_tracer().start_span(f"{attributes[OtelAttr.OPERATION]} {attributes[span_name_attribute]}") span.set_attributes(attributes) with trace.use_span( @@ -1353,7 +1358,8 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]: attributes[SpanAttributes.LLM_SYSTEM] = system_name if provider_name := kwargs.get("provider_name"): attributes[OtelAttr.PROVIDER_NAME] = provider_name - attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model", "unknown") + if model_id := kwargs.get("model", chat_options.model_id): + attributes[SpanAttributes.LLM_REQUEST_MODEL] = model_id if service_url := kwargs.get("service_url"): attributes[OtelAttr.ADDRESS] = service_url if conversation_id := kwargs.get("conversation_id", chat_options.conversation_id): diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 279180e0eef..149fe4bfac4 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -293,6 +293,14 @@ def _tools_to_response_tools( # Map the parameter name and remove the old one mapped_tool[api_param] = mapped_tool.pop(user_param) + # Validate partial_images parameter for streaming image generation + # OpenAI API requires partial_images to be between 0-3 (inclusive) for image_generation tool + # Reference: https://platform.openai.com/docs/api-reference/responses/create#responses_create-tools-image_generation_tool-partial_images + if "partial_images" in mapped_tool: + partial_images = mapped_tool["partial_images"] + if not isinstance(partial_images, int) or partial_images < 0 or partial_images > 3: + raise ValueError("partial_images must be an integer between 0 and 3 (inclusive).") + response_tools.append(mapped_tool) else: response_tools.append(tool_dict) @@ -695,29 +703,8 @@ def _create_response_content( uri = item.result media_type = None if not uri.startswith("data:"): - # Raw base64 string - convert to proper data URI format - # Detect format from base64 data - import base64 - - try: - # Decode a small portion to detect format - decoded_data = base64.b64decode(uri[:100]) # First ~75 bytes should be enough - if decoded_data.startswith(b"\x89PNG"): - format_type = "png" - elif decoded_data.startswith(b"\xff\xd8\xff"): - format_type = "jpeg" - elif decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]: - format_type = "webp" - elif decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"): - format_type = "gif" - else: - # Default to png if format cannot be detected - format_type = "png" - except Exception: - # Fallback to png if decoding fails - format_type = "png" - uri = f"data:image/{format_type};base64,{uri}" - media_type = f"image/{format_type}" + # Raw base64 string - convert to proper data URI format using helper + uri, media_type = DataContent.create_data_uri_from_base64(uri) else: # Parse media type from existing data URI try: @@ -933,6 +920,25 @@ def _create_streaming_response_content( raw_representation=event, ) ) + case "response.image_generation_call.partial_image": + # Handle streaming partial image generation + image_base64 = event.partial_image_b64 + partial_index = event.partial_image_index + + # Use helper function to create data URI from base64 + uri, media_type = DataContent.create_data_uri_from_base64(image_base64) + + contents.append( + DataContent( + uri=uri, + media_type=media_type, + additional_properties={ + "partial_image_index": partial_index, + "is_partial_image": True, + }, + raw_representation=event, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 310fcc4c9c2..0dc26386c2e 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -42,13 +42,14 @@ dependencies = [ [project.optional-dependencies] all = [ "agent-framework-a2a", + "agent-framework-ag-ui", + "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-copilotstudio", - "agent-framework-mem0", - "agent-framework-redis", "agent-framework-devui", + "agent-framework-mem0", "agent-framework-purview", - "agent-framework-anthropic", + "agent-framework-redis", ] [tool.uv] diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index c79f31dca4b..d994867f6a8 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -279,6 +279,45 @@ async def test_chat_client_streaming_observability( assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None +async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test telemetry shouldn't fail when the model_id is not provided for unknown reason.""" + client = use_observability(mock_chat_client)() + messages = [ChatMessage(role=Role.USER, text="Test")] + span_exporter.clear() + response = await client.get_response(messages=messages) + + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + assert span.name == "chat unknown" + assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION + assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown" + + +async def test_chat_client_streaming_without_model_id_observability( + mock_chat_client, span_exporter: InMemorySpanExporter +): + """Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason.""" + client = use_observability(mock_chat_client)() + messages = [ChatMessage(role=Role.USER, text="Test")] + span_exporter.clear() + # Collect all yielded updates + updates = [] + async for update in client.get_streaming_response(messages=messages): + updates.append(update) + + # Verify we got the expected updates, this shouldn't be dependent on otel + assert len(updates) == 2 + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "chat unknown" + assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION + assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown" + + def test_prepend_user_agent_with_none_value(): """Test prepend user agent with None value in headers.""" headers = {"User-Agent": None} @@ -368,6 +407,7 @@ def __init__(self): self.name = "test_agent" self.display_name = "Test Agent" self.description = "Test agent description" + self.chat_options = ChatOptions(model_id="TestModel") async def run(self, messages=None, *, thread=None, **kwargs): return AgentRunResponse( @@ -405,7 +445,7 @@ async def test_agent_instrumentation_enabled( assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id" assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent" assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description" - assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown" + assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel" assert span.attributes[OtelAttr.INPUT_TOKENS] == 15 assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25 if enable_sensitive_data: @@ -433,7 +473,7 @@ async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator( assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id" assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent" assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description" - assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown" + assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel" if enable_sensitive_data: assert span.attributes.get(OtelAttr.OUTPUT_MESSAGES) is not None # Streaming, so no usage yet diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 909e72a0a05..38a3fe414ef 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import base64 from collections.abc import AsyncIterable from typing import Any @@ -166,6 +167,57 @@ def test_data_content_empty(): DataContent(uri="") +def test_data_content_detect_image_format_from_base64(): + """Test the detect_image_format_from_base64 static method.""" + # Test each supported format + png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(png_data).decode()) == "png" + + jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(jpeg_data).decode()) == "jpeg" + + webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(webp_data).decode()) == "webp" + + gif_data = b"GIF89a" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(gif_data).decode()) == "gif" + + # Test fallback behavior + unknown_data = b"UNKNOWN_FORMAT" + assert DataContent.detect_image_format_from_base64(base64.b64encode(unknown_data).decode()) == "png" + + # Test error handling + assert DataContent.detect_image_format_from_base64("invalid_base64!") == "png" + assert DataContent.detect_image_format_from_base64("") == "png" + + +def test_data_content_create_data_uri_from_base64(): + """Test the create_data_uri_from_base64 class method.""" + # Test with PNG data + png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data" + png_base64 = base64.b64encode(png_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(png_base64) + + assert uri == f"data:image/png;base64,{png_base64}" + assert media_type == "image/png" + + # Test with different format + jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data" + jpeg_base64 = base64.b64encode(jpeg_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(jpeg_base64) + + assert uri == f"data:image/jpeg;base64,{jpeg_base64}" + assert media_type == "image/jpeg" + + # Test fallback for unknown format + unknown_data = b"UNKNOWN_FORMAT" + unknown_base64 = base64.b64encode(unknown_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(unknown_base64) + + assert uri == f"data:image/png;base64,{unknown_base64}" + assert media_type == "image/png" + + # region UriContent diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index cb4f0dc0d36..5ff4bb3de3b 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -36,6 +36,7 @@ HostedMCPTool, HostedVectorStoreContent, HostedWebSearchTool, + MCPStreamableHTTPTool, Role, TextContent, TextReasoningContent, @@ -946,1169 +947,1196 @@ def test_streaming_response_basic_structure() -> None: assert response.raw_representation is mock_event -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_response() -> None: - """Test OpenAI chat completion responses.""" - openai_responses_client = OpenAIResponsesClient() - - assert isinstance(openai_responses_client, ChatClientProtocol) +def test_service_response_exception_includes_original_error_details() -> None: + """Test that ServiceResponseException messages include original error details in the new format.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages = [ChatMessage(role="user", text="test message")] - messages: list[ChatMessage] = [] - messages.append( - ChatMessage( - role="user", - text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change.", - ) + mock_response = MagicMock() + original_error_message = "Request rate limit exceeded" + mock_error = BadRequestError( + message=original_error_message, + response=mock_response, + body={"error": {"code": "rate_limit", "message": original_error_message}}, ) - messages.append(ChatMessage(role="user", text="who are Emily and David?")) + mock_error.code = "rate_limit" - # Test that the client can be used to get a response - response = await openai_responses_client.get_response(messages=messages) + with ( + patch.object(client.client.responses, "parse", side_effect=mock_error), + pytest.raises(ServiceResponseException) as exc_info, + ): + asyncio.run(client.get_response(messages=messages, response_format=OutputStruct)) - assert response is not None - assert isinstance(response, ChatResponse) - assert "scientists" in response.text + exception_message = str(exc_info.value) + assert "service failed to complete the prompt:" in exception_message + assert original_error_message in exception_message - messages.clear() - messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny")) - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) - # Test that the client can be used to get a response - response = await openai_responses_client.get_response( - messages=messages, - response_format=OutputStruct, - ) +def test_get_streaming_response_with_response_format() -> None: + """Test get_streaming_response with response_format.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages = [ChatMessage(role="user", text="Test streaming with format")] - assert response is not None - assert isinstance(response, ChatResponse) - output = response.value - assert output is not None, "Response value is None" - assert "seattle" in output.location.lower() - assert output.weather is not None + # It will fail due to invalid API key, but exercises the code path + with pytest.raises(ServiceResponseException): + async def run_streaming(): + async for _ in client.get_streaming_response(messages=messages, response_format=OutputStruct): + pass -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_response_tools() -> None: - """Test OpenAI chat completion responses.""" - openai_responses_client = OpenAIResponsesClient() + asyncio.run(run_streaming()) - assert isinstance(openai_responses_client, ChatClientProtocol) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What is the weather in New York?")) +def test_openai_content_parser_image_content() -> None: + """Test _openai_content_parser with image content variations.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Test that the client can be used to get a response - response = await openai_responses_client.get_response( - messages=messages, - tools=[get_weather], - tool_choice="auto", + # Test image content with detail parameter and file_id + image_content_with_detail = UriContent( + uri="https://example.com/image.jpg", + media_type="image/jpeg", + additional_properties={"detail": "high", "file_id": "file_123"}, ) + result = client._openai_content_parser(Role.USER, image_content_with_detail, {}) # type: ignore + assert result["type"] == "input_image" + assert result["image_url"] == "https://example.com/image.jpg" + assert result["detail"] == "high" + assert result["file_id"] == "file_123" - assert response is not None - assert isinstance(response, ChatResponse) - assert "sunny" in response.text.lower() - - messages.clear() - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + # Test image content without additional properties (defaults) + image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png") + result = client._openai_content_parser(Role.USER, image_content_basic, {}) # type: ignore + assert result["type"] == "input_image" + assert result["detail"] == "auto" + assert result["file_id"] is None - # Test that the client can be used to get a response - response = await openai_responses_client.get_response( - messages=messages, - tools=[get_weather], - tool_choice="auto", - response_format=OutputStruct, - ) - assert response is not None - assert isinstance(response, ChatResponse) - output = OutputStruct.model_validate_json(response.text) - assert "seattle" in output.location.lower() - assert "sunny" in output.weather.lower() +def test_openai_content_parser_audio_content() -> None: + """Test _openai_content_parser with audio content variations.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + # Test WAV audio content + wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav") + result = client._openai_content_parser(Role.USER, wav_content, {}) # type: ignore + assert result["type"] == "input_audio" + assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123" + assert result["input_audio"]["format"] == "wav" -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_streaming() -> None: - """Test OpenAI chat completion responses.""" - openai_responses_client = OpenAIResponsesClient() + # Test MP3 audio content + mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3") + result = client._openai_content_parser(Role.USER, mp3_content, {}) # type: ignore + assert result["type"] == "input_audio" + assert result["input_audio"]["format"] == "mp3" - assert isinstance(openai_responses_client, ChatClientProtocol) - messages: list[ChatMessage] = [] - messages.append( - ChatMessage( - role="user", - text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " - "Bonded by their love for the natural world and shared curiosity, they uncovered a " - "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " - "of climate change.", - ) - ) - messages.append(ChatMessage(role="user", text="who are Emily and David?")) +def test_openai_content_parser_unsupported_content() -> None: + """Test _openai_content_parser with unsupported content types.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Test that the client can be used to get a response - response = await ChatResponse.from_chat_response_generator( - openai_responses_client.get_streaming_response(messages=messages) - ) + # Test unsupported audio format + unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") + result = client._openai_content_parser(Role.USER, unsupported_audio, {}) # type: ignore + assert result == {} - assert "scientists" in response.text + # Test non-media content + text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain") + result = client._openai_content_parser(Role.USER, text_uri_content, {}) # type: ignore + assert result == {} - messages.clear() - messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny")) - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) - response = openai_responses_client.get_streaming_response( - messages=messages, - response_format=OutputStruct, - ) - chunks = [] - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - chunks.append(chunk) - full_message = ChatResponse.from_chat_response_updates(chunks, output_format_type=OutputStruct) - output = full_message.value - assert output is not None, "Response value is None" - assert "seattle" in output.location.lower() - assert output.weather is not None +def test_create_streaming_response_content_code_interpreter() -> None: + """Test _create_streaming_response_content with code_interpreter_call.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + mock_event_image = MagicMock() + mock_event_image.type = "response.output_item.added" + mock_item_image = MagicMock() + mock_item_image.type = "code_interpreter_call" + mock_image_output = MagicMock() + mock_image_output.type = "image" + mock_image_output.url = "https://example.com/plot.png" + mock_item_image.outputs = [mock_image_output] + mock_item_image.code = None + mock_event_image.item = mock_item_image -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_streaming_tools() -> None: - """Test OpenAI chat completion responses.""" - openai_responses_client = OpenAIResponsesClient() + result = client._create_streaming_response_content(mock_event_image, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert isinstance(result.contents[0], UriContent) + assert result.contents[0].uri == "https://example.com/plot.png" + assert result.contents[0].media_type == "image" - assert isinstance(openai_responses_client, ChatClientProtocol) - messages: list[ChatMessage] = [ChatMessage(role="user", text="What is the weather in Seattle?")] +def test_create_streaming_response_content_reasoning() -> None: + """Test _create_streaming_response_content with reasoning content.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} - # Test that the client can be used to get a response - response = openai_responses_client.get_streaming_response( - messages=messages, - tools=[get_weather], - tool_choice="auto", - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if isinstance(content, TextContent) and content.text: - full_message += content.text + mock_event_reasoning = MagicMock() + mock_event_reasoning.type = "response.output_item.added" + mock_item_reasoning = MagicMock() + mock_item_reasoning.type = "reasoning" + mock_reasoning_content = MagicMock() + mock_reasoning_content.text = "Analyzing the problem step by step..." + mock_item_reasoning.content = [mock_reasoning_content] + mock_item_reasoning.summary = ["Problem analysis summary"] + mock_event_reasoning.item = mock_item_reasoning - assert "sunny" in full_message.lower() + result = client._create_streaming_response_content(mock_event_reasoning, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert isinstance(result.contents[0], TextReasoningContent) + assert result.contents[0].text == "Analyzing the problem step by step..." + if result.contents[0].additional_properties: + assert result.contents[0].additional_properties["summary"] == "Problem analysis summary" - messages.clear() - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) - response = openai_responses_client.get_streaming_response( - messages=messages, - tools=[get_weather], - tool_choice="auto", - response_format=OutputStruct, - ) - chunks = [] - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - chunks.append(chunk) - - full_message = ChatResponse.from_chat_response_updates(chunks, output_format_type=OutputStruct) - output = full_message.value - assert output is not None, "Response value is None" - assert "seattle" in output.location.lower() - assert "sunny" in output.weather.lower() +def test_openai_content_parser_text_reasoning_comprehensive() -> None: + """Test _openai_content_parser with TextReasoningContent all additional properties.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + # Test TextReasoningContent with all additional properties + comprehensive_reasoning = TextReasoningContent( + text="Comprehensive reasoning summary", + additional_properties={ + "status": "in_progress", + "reasoning_text": "Step-by-step analysis", + "encrypted_content": "secure_data_456", + }, + ) + result = client._openai_content_parser(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore + assert result["type"] == "reasoning" + assert result["summary"]["text"] == "Comprehensive reasoning summary" + assert result["status"] == "in_progress" + assert result["content"]["type"] == "reasoning_text" + assert result["content"]["text"] == "Step-by-step analysis" + assert result["encrypted_content"] == "secure_data_456" -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_web_search() -> None: - openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) +def test_streaming_reasoning_text_delta_event() -> None: + """Test reasoning text delta event creates TextReasoningContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} - # Test that the client will use the web search tool - response = await openai_responses_client.get_response( - messages=[ - ChatMessage( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - tools=[HostedWebSearchTool()], - tool_choice="auto", + event = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id="reasoning_123", + output_index=0, + sequence_number=1, + delta="reasoning delta", ) - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } - response = await openai_responses_client.get_response( - messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")], - tools=[HostedWebSearchTool(additional_properties=additional_properties)], - tool_choice="auto", - ) - assert response.text is not None + with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "reasoning delta" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_web_search_streaming() -> None: - openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) +def test_streaming_reasoning_text_done_event() -> None: + """Test reasoning text done event creates TextReasoningContent with complete text.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} - # Test that the client will use the web search tool - response = openai_responses_client.get_streaming_response( - messages=[ - ChatMessage( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - tools=[HostedWebSearchTool()], - tool_choice="auto", + event = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id="reasoning_456", + output_index=0, + sequence_number=2, + text="complete reasoning", ) - assert response is not None - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if isinstance(content, TextContent) and content.text: - full_message += content.text - assert "Rumi" in full_message - assert "Mira" in full_message - assert "Zoey" in full_message - - # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } - response = openai_responses_client.get_streaming_response( - messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")], - tools=[HostedWebSearchTool(additional_properties=additional_properties)], - tool_choice="auto", - ) - assert response is not None - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if isinstance(content, TextContent) and content.text: - full_message += content.text - assert full_message is not None + with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "complete reasoning" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"test": "data"} -@pytest.mark.skip( - reason="Unreliable due to OpenAI vector store indexing potential " - "race condition. See https://github.com/microsoft/agent-framework/issues/1669" -) -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_file_search() -> None: - openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) +def test_streaming_reasoning_summary_text_delta_event() -> None: + """Test reasoning summary text delta event creates TextReasoningContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} - file_id, vector_store = await create_vector_store(openai_responses_client) - # Test that the client will use the web search tool - response = await openai_responses_client.get_response( - messages=[ - ChatMessage( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - tools=[HostedFileSearchTool(inputs=vector_store)], - tool_choice="auto", + event = ResponseReasoningSummaryTextDeltaEvent( + type="response.reasoning_summary_text.delta", + item_id="summary_789", + output_index=0, + sequence_number=3, + summary_index=0, + delta="summary delta", ) - await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id) - assert "sunny" in response.text.lower() - assert "75" in response.text + with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "summary delta" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) -@pytest.mark.skip( - reason="Unreliable due to OpenAI vector store indexing " - "potential race condition. See https://github.com/microsoft/agent-framework/issues/1669" -) -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_streaming_file_search() -> None: - openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) +def test_streaming_reasoning_summary_text_done_event() -> None: + """Test reasoning summary text done event creates TextReasoningContent with complete text.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} - file_id, vector_store = await create_vector_store(openai_responses_client) - # Test that the client will use the web search tool - response = openai_responses_client.get_streaming_response( - messages=[ - ChatMessage( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - tools=[HostedFileSearchTool(inputs=vector_store)], - tool_choice="auto", + event = ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id="summary_012", + output_index=0, + sequence_number=4, + summary_index=0, + text="complete summary", ) - assert response is not None - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if isinstance(content, TextContent) and content.text: - full_message += content.text + with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id) + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "complete summary" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"custom": "meta"} - assert "sunny" in full_message.lower() - assert "75" in full_message +def test_streaming_reasoning_events_preserve_metadata() -> None: + """Test that reasoning events preserve metadata like regular text events.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_basic_run(): - """Test OpenAI Responses Client agent basic run functionality with OpenAIResponsesClient.""" - agent = OpenAIResponsesClient().create_agent( - instructions="You are a helpful assistant.", + text_event = ResponseTextDeltaEvent( + type="response.output_text.delta", + content_index=0, + item_id="text_item", + output_index=0, + sequence_number=1, + logprobs=[], + delta="text", ) - # Test basic run - response = await agent.run("Hello! Please respond with 'Hello World' exactly.") + reasoning_event = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id="reasoning_item", + output_index=0, + sequence_number=2, + delta="reasoning", + ) - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "hello world" in response.text.lower() + with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}): + text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore + reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore + # Both should preserve metadata + assert text_response.additional_properties == {"test": "metadata"} + assert reasoning_response.additional_properties == {"test": "metadata"} -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_basic_run_streaming(): - """Test OpenAI Responses Client agent basic streaming functionality with OpenAIResponsesClient.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - ) as agent: - # Test streaming run - full_text = "" - async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"): - assert isinstance(chunk, AgentRunResponseUpdate) - if chunk.text: - full_text += chunk.text + # Content types should be different + assert isinstance(text_response.contents[0], TextContent) + assert isinstance(reasoning_response.contents[0], TextReasoningContent) - assert len(full_text) > 0 - assert "streaming response test" in full_text.lower() +def test_create_response_content_image_generation_raw_base64(): + """Test image generation response parsing with raw base64 string.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_thread_persistence(): - """Test OpenAI Responses Client agent thread persistence across runs with OpenAIResponsesClient.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a mock response with raw base64 image data (PNG signature) + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 - # First interaction - first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread) + # Mock image generation output item with raw base64 (PNG format) + png_signature = b"\x89PNG\r\n\x1a\n" + mock_base64 = base64.b64encode(png_signature + b"fake_png_data_here").decode() - assert isinstance(first_response, AgentRunResponse) - assert first_response.text is not None + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = mock_base64 - # Second interaction - test memory - second_response = await agent.run("What is my favorite programming language?", thread=thread) + mock_response.output = [mock_item] - assert isinstance(second_response, AgentRunResponse) - assert second_response.text is not None + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + # Verify the response contains DataContent with proper URI and media_type + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.uri.startswith("data:image/png;base64,") + assert content.media_type == "image/png" -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_thread_storage_with_store_true(): - """Test OpenAI Responses Client agent with store=True to verify service_thread_id is returned.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant.", - ) as agent: - # Create a new thread - thread = AgentThread() - # Initially, service_thread_id should be None - assert thread.service_thread_id is None +def test_create_response_content_image_generation_existing_data_uri(): + """Test image generation response parsing with existing data URI.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Run with store=True to store messages on OpenAI side - response = await agent.run( - "Hello! Please remember that my name is Alex.", - thread=thread, - store=True, - ) + # Create a mock response with existing data URI + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 - # Validate response - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 + # Mock image generation output item with existing data URI (valid WEBP header) + webp_signature = b"RIFF" + b"\x12\x00\x00\x00" + b"WEBP" + valid_webp_base64 = base64.b64encode(webp_signature + b"VP8 fake_data").decode() + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = f"data:image/webp;base64,{valid_webp_base64}" - # After store=True, service_thread_id should be populated - assert thread.service_thread_id is not None - assert isinstance(thread.service_thread_id, str) - assert len(thread.service_thread_id) > 0 + mock_response.output = [mock_item] + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_existing_thread(): - """Test OpenAI Responses Client agent with existing thread to continue conversations across agent instances.""" - # First conversation - capture the thread - preserved_thread = None + # Verify the response contains DataContent with proper media_type parsed from URI + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.uri == f"data:image/webp;base64,{valid_webp_base64}" + assert content.media_type == "image/webp" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and capture the thread - thread = first_agent.get_new_thread() - first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread) - assert isinstance(first_response, AgentRunResponse) - assert first_response.text is not None +def test_create_response_content_image_generation_format_detection(): + """Test different image format detection from base64 data.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Preserve the thread for reuse - preserved_thread = thread + # Test JPEG detection + jpeg_signature = b"\xff\xd8\xff" + mock_base64_jpeg = base64.b64encode(jpeg_signature + b"fake_jpeg_data").decode() - # Second conversation - reuse the thread in a new agent instance - if preserved_thread: - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Reuse the preserved thread - second_response = await second_agent.run("What is my hobby?", thread=preserved_thread) + mock_response_jpeg = MagicMock() + mock_response_jpeg.output_parsed = None + mock_response_jpeg.metadata = {} + mock_response_jpeg.usage = None + mock_response_jpeg.id = "test-id" + mock_response_jpeg.model = "test-model" + mock_response_jpeg.created_at = 1234567890 - assert isinstance(second_response, AgentRunResponse) - assert second_response.text is not None - assert "photography" in second_response.text.lower() + mock_item_jpeg = MagicMock() + mock_item_jpeg.type = "image_generation_call" + mock_item_jpeg.result = mock_base64_jpeg + mock_response_jpeg.output = [mock_item_jpeg] + with patch.object(client, "_get_metadata_from_response", return_value={}): + response_jpeg = client._create_response_content(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore + content_jpeg = response_jpeg.messages[0].contents[0] + assert isinstance(content_jpeg, DataContent) + assert content_jpeg.media_type == "image/jpeg" + assert "data:image/jpeg;base64," in content_jpeg.uri -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_hosted_code_interpreter_tool(): - """Test OpenAI Responses Client agent with HostedCodeInterpreterTool through OpenAIResponsesClient.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant that can execute Python code.", - tools=[HostedCodeInterpreterTool()], - ) as agent: - # Test code interpreter functionality - response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.") + # Test WEBP detection + webp_signature = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + mock_base64_webp = base64.b64encode(webp_signature + b"fake_webp_data").decode() - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 - # Should contain calculation result (sum of 1-10 = 55) or code execution content - contains_relevant_content = any( - term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"] - ) - assert contains_relevant_content or len(response.text.strip()) > 10 + mock_response_webp = MagicMock() + mock_response_webp.output_parsed = None + mock_response_webp.metadata = {} + mock_response_webp.usage = None + mock_response_webp.id = "test-id" + mock_response_webp.model = "test-model" + mock_response_webp.created_at = 1234567890 + mock_item_webp = MagicMock() + mock_item_webp.type = "image_generation_call" + mock_item_webp.result = mock_base64_webp + mock_response_webp.output = [mock_item_webp] -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_raw_image_generation_tool(): - """Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant that can generate images.", - tools=[{"type": "image_generation", "size": "1024x1024", "quality": "low", "format": "png"}], - ) as agent: - # Test image generation functionality - response = await agent.run("Generate an image of a cute red panda sitting on a tree branch in a forest.") + with patch.object(client, "_get_metadata_from_response", return_value={}): + response_webp = client._create_response_content(mock_response_webp, chat_options=ChatOptions()) # type: ignore + content_webp = response_webp.messages[0].contents[0] + assert isinstance(content_webp, DataContent) + assert content_webp.media_type == "image/webp" + assert "data:image/webp;base64," in content_webp.uri - assert isinstance(response, AgentRunResponse) - # For image generation, we expect to get some response content - # This could be DataContent with image data, UriContent - assert response.messages is not None and len(response.messages) > 0 +def test_create_response_content_image_generation_fallback(): + """Test image generation with invalid base64 falls back to PNG.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Check that we have some kind of content in the response - total_contents = sum(len(message.contents) for message in response.messages) - assert total_contents > 0, f"Expected some content in response messages, got {total_contents} contents" + # Create a mock response with invalid base64 + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 - # Verify we got image content - look for DataContent with URI starting with "data:image" - image_content_found = False - for message in response.messages: - for content in message.contents: - uri = getattr(content, "uri", None) - if uri and uri.startswith("data:image"): - image_content_found = True - break - if image_content_found: - break + # Mock image generation output item with unrecognized format (should fall back to PNG) + unrecognized_data = b"UNKNOWN_FORMAT" + b"some_binary_data" + unrecognized_base64 = base64.b64encode(unrecognized_data).decode() + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = unrecognized_base64 - # The test passes if we got image content (which we did based on the visible base64 output) - assert image_content_found, "Expected to find image content in response" + mock_response.output = [mock_item] + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with OpenAI Responses Client.""" + # Verify it falls back to PNG format for unrecognized binary data + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.media_type == "image/png" + assert f"data:image/png;base64,{unrecognized_base64}" == content.uri - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], # Agent-level tool - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - assert isinstance(first_response, AgentRunResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) +def test_prepare_options_store_parameter_handling() -> None: + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages = [ChatMessage(role="user", text="Test message")] - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") + test_conversation_id = "test-conversation-123" + chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is True + assert options["previous_response_id"] == test_conversation_id - assert isinstance(second_response, AgentRunResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) + chat_options = ChatOptions(store=False, conversation_id="") + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + chat_options = ChatOptions(store=None, conversation_id=None) + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + assert "previous_response_id" not in options -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_run_level_tool_isolation(): - """Test that run-level tools are isolated to specific runs and don't persist with OpenAI Responses Client.""" - # Counter to track how many times the weather tool is called - call_count = 0 + chat_options = ChatOptions() + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + assert "previous_response_id" not in options - @ai_function - async def get_weather_with_counter(location: Annotated[str, "The location as a city name"]) -> str: - """Get the current weather in a given location.""" - nonlocal call_count - call_count += 1 - return f"The weather in {location} is sunny and 72°F." - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant.", - ) as agent: - # First run - use run-level tool - first_response = await agent.run( - "What's the weather like in Chicago?", - tools=[get_weather_with_counter], # Run-level tool - ) +def test_openai_responses_client_with_callable_api_key() -> None: + """Test OpenAIResponsesClient initialization with callable API key.""" - assert isinstance(first_response, AgentRunResponse) - assert first_response.text is not None - # Should use the run-level weather tool (call count should be 1) - assert call_count == 1 - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) + async def get_api_key() -> str: + return "test-api-key-123" - # Second run - run-level tool should NOT persist (key isolation test) - second_response = await agent.run("What's the weather like in Miami?") + client = OpenAIResponsesClient(model_id="gpt-4o", api_key=get_api_key) - assert isinstance(second_response, AgentRunResponse) - assert second_response.text is not None - # Should NOT use the weather tool since it was only run-level in previous call - # Call count should still be 1 (no additional calls) - assert call_count == 1 + # Verify client was created successfully + assert client.model_id == "gpt-4o" + # OpenAI SDK now manages callable API keys internally + assert client.client is not None @pytest.mark.flaky @skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_chat_options_run_level() -> None: - """Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant.", - ) as agent: - response = await agent.run( - "Provide a brief, helpful response about why the sky blue is.", - max_tokens=600, - model_id="gpt-4o", - user="comprehensive-test-user", - tools=[get_weather], - tool_choice="auto", - ) - - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 +async def test_openai_responses_client_response() -> None: + """Test OpenAI chat completion responses.""" + openai_responses_client = OpenAIResponsesClient() + assert isinstance(openai_responses_client, ChatClientProtocol) -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_chat_options_agent_level() -> None: - """Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent.""" - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant.", - max_tokens=100, - temperature=0.7, - top_p=0.9, - seed=123, - user="comprehensive-test-user", - tools=[get_weather], - tool_choice="auto", - ) as agent: - response = await agent.run( - "Provide a brief, helpful response.", + messages: list[ChatMessage] = [] + messages.append( + ChatMessage( + role="user", + text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change.", ) + ) + messages.append(ChatMessage(role="user", text="who are Emily and David?")) - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 + # Test that the client can be used to get a response + response = await openai_responses_client.get_response(messages=messages) + assert response is not None + assert isinstance(response, ChatResponse) + assert "scientists" in response.text -@pytest.mark.flaky -@skip_if_openai_integration_tests_disabled -async def test_openai_responses_client_agent_hosted_mcp_tool() -> None: - """Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP.""" + messages.clear() + messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny")) + messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) - mcp_tool = HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - description="A Microsoft Learn MCP server for documentation questions", - approval_mode="never_require", + # Test that the client can be used to get a response + response = await openai_responses_client.get_response( + messages=messages, + response_format=OutputStruct, ) - async with ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=[mcp_tool], - ) as agent: - response = await agent.run( - "How to create an Azure storage account using az cli?", - max_tokens=200, - ) + assert response is not None + assert isinstance(response, ChatResponse) + output = response.value + assert output is not None, "Response value is None" + assert "seattle" in output.location.lower() + assert output.weather is not None - assert isinstance(response, AgentRunResponse) - assert response.text is not None - assert len(response.text) > 0 - # Should contain Azure-related content since it's asking about Azure CLI - assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_response_tools() -> None: + """Test OpenAI chat completion responses.""" + openai_responses_client = OpenAIResponsesClient() -def test_service_response_exception_includes_original_error_details() -> None: - """Test that ServiceResponseException messages include original error details in the new format.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="test message")] + assert isinstance(openai_responses_client, ChatClientProtocol) - mock_response = MagicMock() - original_error_message = "Request rate limit exceeded" - mock_error = BadRequestError( - message=original_error_message, - response=mock_response, - body={"error": {"code": "rate_limit", "message": original_error_message}}, + messages: list[ChatMessage] = [] + messages.append(ChatMessage(role="user", text="What is the weather in New York?")) + + # Test that the client can be used to get a response + response = await openai_responses_client.get_response( + messages=messages, + tools=[get_weather], + tool_choice="auto", ) - mock_error.code = "rate_limit" - with ( - patch.object(client.client.responses, "parse", side_effect=mock_error), - pytest.raises(ServiceResponseException) as exc_info, - ): - asyncio.run(client.get_response(messages=messages, response_format=OutputStruct)) + assert response is not None + assert isinstance(response, ChatResponse) + assert "sunny" in response.text.lower() - exception_message = str(exc_info.value) - assert "service failed to complete the prompt:" in exception_message - assert original_error_message in exception_message + messages.clear() + messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + # Test that the client can be used to get a response + response = await openai_responses_client.get_response( + messages=messages, + tools=[get_weather], + tool_choice="auto", + response_format=OutputStruct, + ) -def test_get_streaming_response_with_response_format() -> None: - """Test get_streaming_response with response_format.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test streaming with format")] + assert response is not None + assert isinstance(response, ChatResponse) + output = OutputStruct.model_validate_json(response.text) + assert "seattle" in output.location.lower() + assert "sunny" in output.weather.lower() - # It will fail due to invalid API key, but exercises the code path - with pytest.raises(ServiceResponseException): - async def run_streaming(): - async for _ in client.get_streaming_response(messages=messages, response_format=OutputStruct): - pass +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_streaming() -> None: + """Test OpenAI chat completion responses.""" + openai_responses_client = OpenAIResponsesClient() - asyncio.run(run_streaming()) + assert isinstance(openai_responses_client, ChatClientProtocol) + messages: list[ChatMessage] = [] + messages.append( + ChatMessage( + role="user", + text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change.", + ) + ) + messages.append(ChatMessage(role="user", text="who are Emily and David?")) -def test_openai_content_parser_image_content() -> None: - """Test _openai_content_parser with image content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + # Test that the client can be used to get a response + response = await ChatResponse.from_chat_response_generator( + openai_responses_client.get_streaming_response(messages=messages) + ) - # Test image content with detail parameter and file_id - image_content_with_detail = UriContent( - uri="https://example.com/image.jpg", - media_type="image/jpeg", - additional_properties={"detail": "high", "file_id": "file_123"}, + assert "scientists" in response.text + + messages.clear() + messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny")) + messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + + response = openai_responses_client.get_streaming_response( + messages=messages, + response_format=OutputStruct, ) - result = client._openai_content_parser(Role.USER, image_content_with_detail, {}) # type: ignore - assert result["type"] == "input_image" - assert result["image_url"] == "https://example.com/image.jpg" - assert result["detail"] == "high" - assert result["file_id"] == "file_123" + chunks = [] + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + chunks.append(chunk) + full_message = ChatResponse.from_chat_response_updates(chunks, output_format_type=OutputStruct) + output = full_message.value + assert output is not None, "Response value is None" + assert "seattle" in output.location.lower() + assert output.weather is not None - # Test image content without additional properties (defaults) - image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png") - result = client._openai_content_parser(Role.USER, image_content_basic, {}) # type: ignore - assert result["type"] == "input_image" - assert result["detail"] == "auto" - assert result["file_id"] is None +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_streaming_tools() -> None: + """Test OpenAI chat completion responses.""" + openai_responses_client = OpenAIResponsesClient() -def test_openai_content_parser_audio_content() -> None: - """Test _openai_content_parser with audio content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + assert isinstance(openai_responses_client, ChatClientProtocol) - # Test WAV audio content - wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav") - result = client._openai_content_parser(Role.USER, wav_content, {}) # type: ignore - assert result["type"] == "input_audio" - assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123" - assert result["input_audio"]["format"] == "wav" + messages: list[ChatMessage] = [ChatMessage(role="user", text="What is the weather in Seattle?")] - # Test MP3 audio content - mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3") - result = client._openai_content_parser(Role.USER, mp3_content, {}) # type: ignore - assert result["type"] == "input_audio" - assert result["input_audio"]["format"] == "mp3" + # Test that the client can be used to get a response + response = openai_responses_client.get_streaming_response( + messages=messages, + tools=[get_weather], + tool_choice="auto", + ) + full_message: str = "" + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + for content in chunk.contents: + if isinstance(content, TextContent) and content.text: + full_message += content.text + assert "sunny" in full_message.lower() -def test_openai_content_parser_unsupported_content() -> None: - """Test _openai_content_parser with unsupported content types.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages.clear() + messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) - # Test unsupported audio format - unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") - result = client._openai_content_parser(Role.USER, unsupported_audio, {}) # type: ignore - assert result == {} + response = openai_responses_client.get_streaming_response( + messages=messages, + tools=[get_weather], + tool_choice="auto", + response_format=OutputStruct, + ) + chunks = [] + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + chunks.append(chunk) - # Test non-media content - text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain") - result = client._openai_content_parser(Role.USER, text_uri_content, {}) # type: ignore - assert result == {} + full_message = ChatResponse.from_chat_response_updates(chunks, output_format_type=OutputStruct) + output = full_message.value + assert output is not None, "Response value is None" + assert "seattle" in output.location.lower() + assert "sunny" in output.weather.lower() -def test_create_streaming_response_content_code_interpreter() -> None: - """Test _create_streaming_response_content with code_interpreter_call.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_web_search() -> None: + openai_responses_client = OpenAIResponsesClient() - mock_event_image = MagicMock() - mock_event_image.type = "response.output_item.added" - mock_item_image = MagicMock() - mock_item_image.type = "code_interpreter_call" - mock_image_output = MagicMock() - mock_image_output.type = "image" - mock_image_output.url = "https://example.com/plot.png" - mock_item_image.outputs = [mock_image_output] - mock_item_image.code = None - mock_event_image.item = mock_item_image + assert isinstance(openai_responses_client, ChatClientProtocol) - result = client._create_streaming_response_content(mock_event_image, chat_options, function_call_ids) # type: ignore - assert len(result.contents) == 1 - assert isinstance(result.contents[0], UriContent) - assert result.contents[0].uri == "https://example.com/plot.png" - assert result.contents[0].media_type == "image" + # Test that the client will use the web search tool + response = await openai_responses_client.get_response( + messages=[ + ChatMessage( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + tools=[HostedWebSearchTool()], + tool_choice="auto", + ) + assert response is not None + assert isinstance(response, ChatResponse) + assert "Rumi" in response.text + assert "Mira" in response.text + assert "Zoey" in response.text -def test_create_streaming_response_content_reasoning() -> None: - """Test _create_streaming_response_content with reasoning content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} + # Test that the client will use the web search tool with location + additional_properties = { + "user_location": { + "country": "US", + "city": "Seattle", + } + } + response = await openai_responses_client.get_response( + messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")], + tools=[HostedWebSearchTool(additional_properties=additional_properties)], + tool_choice="auto", + ) + assert response.text is not None - mock_event_reasoning = MagicMock() - mock_event_reasoning.type = "response.output_item.added" - mock_item_reasoning = MagicMock() - mock_item_reasoning.type = "reasoning" - mock_reasoning_content = MagicMock() - mock_reasoning_content.text = "Analyzing the problem step by step..." - mock_item_reasoning.content = [mock_reasoning_content] - mock_item_reasoning.summary = ["Problem analysis summary"] - mock_event_reasoning.item = mock_item_reasoning - result = client._create_streaming_response_content(mock_event_reasoning, chat_options, function_call_ids) # type: ignore - assert len(result.contents) == 1 - assert isinstance(result.contents[0], TextReasoningContent) - assert result.contents[0].text == "Analyzing the problem step by step..." - if result.contents[0].additional_properties: - assert result.contents[0].additional_properties["summary"] == "Problem analysis summary" +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_web_search_streaming() -> None: + openai_responses_client = OpenAIResponsesClient() + assert isinstance(openai_responses_client, ChatClientProtocol) -def test_openai_content_parser_text_reasoning_comprehensive() -> None: - """Test _openai_content_parser with TextReasoningContent all additional properties.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + # Test that the client will use the web search tool + response = openai_responses_client.get_streaming_response( + messages=[ + ChatMessage( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + tools=[HostedWebSearchTool()], + tool_choice="auto", + ) - # Test TextReasoningContent with all additional properties - comprehensive_reasoning = TextReasoningContent( - text="Comprehensive reasoning summary", - additional_properties={ - "status": "in_progress", - "reasoning_text": "Step-by-step analysis", - "encrypted_content": "secure_data_456", - }, + assert response is not None + full_message: str = "" + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + for content in chunk.contents: + if isinstance(content, TextContent) and content.text: + full_message += content.text + assert "Rumi" in full_message + assert "Mira" in full_message + assert "Zoey" in full_message + + # Test that the client will use the web search tool with location + additional_properties = { + "user_location": { + "country": "US", + "city": "Seattle", + } + } + response = openai_responses_client.get_streaming_response( + messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")], + tools=[HostedWebSearchTool(additional_properties=additional_properties)], + tool_choice="auto", ) - result = client._openai_content_parser(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore - assert result["type"] == "reasoning" - assert result["summary"]["text"] == "Comprehensive reasoning summary" - assert result["status"] == "in_progress" - assert result["content"]["type"] == "reasoning_text" - assert result["content"]["text"] == "Step-by-step analysis" - assert result["encrypted_content"] == "secure_data_456" + assert response is not None + full_message: str = "" + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + for content in chunk.contents: + if isinstance(content, TextContent) and content.text: + full_message += content.text + assert full_message is not None -def test_streaming_reasoning_text_delta_event() -> None: - """Test reasoning text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} +@pytest.mark.skip( + reason="Unreliable due to OpenAI vector store indexing potential " + "race condition. See https://github.com/microsoft/agent-framework/issues/1669" +) +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_file_search() -> None: + openai_responses_client = OpenAIResponsesClient() - event = ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - content_index=0, - item_id="reasoning_123", - output_index=0, - sequence_number=1, - delta="reasoning delta", + assert isinstance(openai_responses_client, ChatClientProtocol) + + file_id, vector_store = await create_vector_store(openai_responses_client) + # Test that the client will use the web search tool + response = await openai_responses_client.get_response( + messages=[ + ChatMessage( + role="user", + text="What is the weather today? Do a file search to find the answer.", + ) + ], + tools=[HostedFileSearchTool(inputs=vector_store)], + tool_choice="auto", ) - with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id) + assert "sunny" in response.text.lower() + assert "75" in response.text - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "reasoning delta" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) +@pytest.mark.skip( + reason="Unreliable due to OpenAI vector store indexing " + "potential race condition. See https://github.com/microsoft/agent-framework/issues/1669" +) +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_streaming_file_search() -> None: + openai_responses_client = OpenAIResponsesClient() + + assert isinstance(openai_responses_client, ChatClientProtocol) -def test_streaming_reasoning_text_done_event() -> None: - """Test reasoning text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} + file_id, vector_store = await create_vector_store(openai_responses_client) + # Test that the client will use the web search tool + response = openai_responses_client.get_streaming_response( + messages=[ + ChatMessage( + role="user", + text="What is the weather today? Do a file search to find the answer.", + ) + ], + tools=[HostedFileSearchTool(inputs=vector_store)], + tool_choice="auto", + ) - event = ResponseReasoningTextDoneEvent( - type="response.reasoning_text.done", - content_index=0, - item_id="reasoning_456", - output_index=0, - sequence_number=2, - text="complete reasoning", + assert response is not None + full_message: str = "" + async for chunk in response: + assert chunk is not None + assert isinstance(chunk, ChatResponseUpdate) + for content in chunk.contents: + if isinstance(content, TextContent) and content.text: + full_message += content.text + + await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id) + + assert "sunny" in full_message.lower() + assert "75" in full_message + + +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_basic_run(): + """Test OpenAI Responses Client agent basic run functionality with OpenAIResponsesClient.""" + agent = OpenAIResponsesClient().create_agent( + instructions="You are a helpful assistant.", ) - with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + # Test basic run + response = await agent.run("Hello! Please respond with 'Hello World' exactly.") - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "complete reasoning" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - assert response.additional_properties == {"test": "data"} + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + assert "hello world" in response.text.lower() -def test_streaming_reasoning_summary_text_delta_event() -> None: - """Test reasoning summary text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_basic_run_streaming(): + """Test OpenAI Responses Client agent basic streaming functionality with OpenAIResponsesClient.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + ) as agent: + # Test streaming run + full_text = "" + async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"): + assert isinstance(chunk, AgentRunResponseUpdate) + if chunk.text: + full_text += chunk.text - event = ResponseReasoningSummaryTextDeltaEvent( - type="response.reasoning_summary_text.delta", - item_id="summary_789", - output_index=0, - sequence_number=3, - summary_index=0, - delta="summary delta", - ) + assert len(full_text) > 0 + assert "streaming response test" in full_text.lower() - with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "summary delta" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_thread_persistence(): + """Test OpenAI Responses Client agent thread persistence across runs with OpenAIResponsesClient.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant with good memory.", + ) as agent: + # Create a new thread that will be reused + thread = agent.get_new_thread() + # First interaction + first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread) -def test_streaming_reasoning_summary_text_done_event() -> None: - """Test reasoning summary text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} + assert isinstance(first_response, AgentRunResponse) + assert first_response.text is not None - event = ResponseReasoningSummaryTextDoneEvent( - type="response.reasoning_summary_text.done", - item_id="summary_012", - output_index=0, - sequence_number=4, - summary_index=0, - text="complete summary", - ) + # Second interaction - test memory + second_response = await agent.run("What is my favorite programming language?", thread=thread) - with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + assert isinstance(second_response, AgentRunResponse) + assert second_response.text is not None - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "complete summary" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - assert response.additional_properties == {"custom": "meta"} +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_thread_storage_with_store_true(): + """Test OpenAI Responses Client agent with store=True to verify service_thread_id is returned.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant.", + ) as agent: + # Create a new thread + thread = AgentThread() -def test_streaming_reasoning_events_preserve_metadata() -> None: - """Test that reasoning events preserve metadata like regular text events.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} + # Initially, service_thread_id should be None + assert thread.service_thread_id is None - text_event = ResponseTextDeltaEvent( - type="response.output_text.delta", - content_index=0, - item_id="text_item", - output_index=0, - sequence_number=1, - logprobs=[], - delta="text", - ) + # Run with store=True to store messages on OpenAI side + response = await agent.run( + "Hello! Please remember that my name is Alex.", + thread=thread, + store=True, + ) - reasoning_event = ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - content_index=0, - item_id="reasoning_item", - output_index=0, - sequence_number=2, - delta="reasoning", - ) + # Validate response + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 - with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}): - text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore - reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore + # After store=True, service_thread_id should be populated + assert thread.service_thread_id is not None + assert isinstance(thread.service_thread_id, str) + assert len(thread.service_thread_id) > 0 - # Both should preserve metadata - assert text_response.additional_properties == {"test": "metadata"} - assert reasoning_response.additional_properties == {"test": "metadata"} - # Content types should be different - assert isinstance(text_response.contents[0], TextContent) - assert isinstance(reasoning_response.contents[0], TextReasoningContent) +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_existing_thread(): + """Test OpenAI Responses Client agent with existing thread to continue conversations across agent instances.""" + # First conversation - capture the thread + preserved_thread = None + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant with good memory.", + ) as first_agent: + # Start a conversation and capture the thread + thread = first_agent.get_new_thread() + first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread) -def test_create_response_content_image_generation_raw_base64(): - """Test image generation response parsing with raw base64 string.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + assert isinstance(first_response, AgentRunResponse) + assert first_response.text is not None - # Create a mock response with raw base64 image data (PNG signature) - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 + # Preserve the thread for reuse + preserved_thread = thread - # Mock image generation output item with raw base64 (PNG format) - png_signature = b"\x89PNG\r\n\x1a\n" - mock_base64 = base64.b64encode(png_signature + b"fake_png_data_here").decode() + # Second conversation - reuse the thread in a new agent instance + if preserved_thread: + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant with good memory.", + ) as second_agent: + # Reuse the preserved thread + second_response = await second_agent.run("What is my hobby?", thread=preserved_thread) - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = mock_base64 + assert isinstance(second_response, AgentRunResponse) + assert second_response.text is not None + assert "photography" in second_response.text.lower() - mock_response.output = [mock_item] - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_hosted_code_interpreter_tool(): + """Test OpenAI Responses Client agent with HostedCodeInterpreterTool through OpenAIResponsesClient.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that can execute Python code.", + tools=[HostedCodeInterpreterTool()], + ) as agent: + # Test code interpreter functionality + response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.") - # Verify the response contains DataContent with proper URI and media_type - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.uri.startswith("data:image/png;base64,") - assert content.media_type == "image/png" + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + # Should contain calculation result (sum of 1-10 = 55) or code execution content + contains_relevant_content = any( + term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"] + ) + assert contains_relevant_content or len(response.text.strip()) > 10 -def test_create_response_content_image_generation_existing_data_uri(): - """Test image generation response parsing with existing data URI.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_raw_image_generation_tool(): + """Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that can generate images.", + tools=[{"type": "image_generation", "size": "1024x1024", "quality": "low", "format": "png"}], + ) as agent: + # Test image generation functionality + response = await agent.run("Generate an image of a cute red panda sitting on a tree branch in a forest.") - # Create a mock response with existing data URI - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 + assert isinstance(response, AgentRunResponse) - # Mock image generation output item with existing data URI (valid WEBP header) - webp_signature = b"RIFF" + b"\x12\x00\x00\x00" + b"WEBP" - valid_webp_base64 = base64.b64encode(webp_signature + b"VP8 fake_data").decode() - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = f"data:image/webp;base64,{valid_webp_base64}" + # For image generation, we expect to get some response content + # This could be DataContent with image data, UriContent + assert response.messages is not None and len(response.messages) > 0 + + # Check that we have some kind of content in the response + total_contents = sum(len(message.contents) for message in response.messages) + assert total_contents > 0, f"Expected some content in response messages, got {total_contents} contents" - mock_response.output = [mock_item] + # Verify we got image content - look for DataContent with URI starting with "data:image" + image_content_found = False + for message in response.messages: + for content in message.contents: + uri = getattr(content, "uri", None) + if uri and uri.startswith("data:image"): + image_content_found = True + break + if image_content_found: + break - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + # The test passes if we got image content (which we did based on the visible base64 output) + assert image_content_found, "Expected to find image content in response" - # Verify the response contains DataContent with proper media_type parsed from URI - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.uri == f"data:image/webp;base64,{valid_webp_base64}" - assert content.media_type == "image/webp" +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_level_tool_persistence(): + """Test that agent-level tools persist across multiple runs with OpenAI Responses Client.""" -def test_create_response_content_image_generation_format_detection(): - """Test different image format detection from base64 data.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that uses available tools.", + tools=[get_weather], # Agent-level tool + ) as agent: + # First run - agent-level tool should be available + first_response = await agent.run("What's the weather like in Chicago?") - # Test JPEG detection - jpeg_signature = b"\xff\xd8\xff" - mock_base64_jpeg = base64.b64encode(jpeg_signature + b"fake_jpeg_data").decode() + assert isinstance(first_response, AgentRunResponse) + assert first_response.text is not None + # Should use the agent-level weather tool + assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) - mock_response_jpeg = MagicMock() - mock_response_jpeg.output_parsed = None - mock_response_jpeg.metadata = {} - mock_response_jpeg.usage = None - mock_response_jpeg.id = "test-id" - mock_response_jpeg.model = "test-model" - mock_response_jpeg.created_at = 1234567890 + # Second run - agent-level tool should still be available (persistence test) + second_response = await agent.run("What's the weather in Miami?") - mock_item_jpeg = MagicMock() - mock_item_jpeg.type = "image_generation_call" - mock_item_jpeg.result = mock_base64_jpeg - mock_response_jpeg.output = [mock_item_jpeg] + assert isinstance(second_response, AgentRunResponse) + assert second_response.text is not None + # Should use the agent-level weather tool again + assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) - with patch.object(client, "_get_metadata_from_response", return_value={}): - response_jpeg = client._create_response_content(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore - content_jpeg = response_jpeg.messages[0].contents[0] - assert isinstance(content_jpeg, DataContent) - assert content_jpeg.media_type == "image/jpeg" - assert "data:image/jpeg;base64," in content_jpeg.uri - # Test WEBP detection - webp_signature = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" - mock_base64_webp = base64.b64encode(webp_signature + b"fake_webp_data").decode() +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_run_level_tool_isolation(): + """Test that run-level tools are isolated to specific runs and don't persist with OpenAI Responses Client.""" + # Counter to track how many times the weather tool is called + call_count = 0 - mock_response_webp = MagicMock() - mock_response_webp.output_parsed = None - mock_response_webp.metadata = {} - mock_response_webp.usage = None - mock_response_webp.id = "test-id" - mock_response_webp.model = "test-model" - mock_response_webp.created_at = 1234567890 + @ai_function + async def get_weather_with_counter(location: Annotated[str, "The location as a city name"]) -> str: + """Get the current weather in a given location.""" + nonlocal call_count + call_count += 1 + return f"The weather in {location} is sunny and 72°F." - mock_item_webp = MagicMock() - mock_item_webp.type = "image_generation_call" - mock_item_webp.result = mock_base64_webp - mock_response_webp.output = [mock_item_webp] + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant.", + ) as agent: + # First run - use run-level tool + first_response = await agent.run( + "What's the weather like in Chicago?", + tools=[get_weather_with_counter], # Run-level tool + ) - with patch.object(client, "_get_metadata_from_response", return_value={}): - response_webp = client._create_response_content(mock_response_webp, chat_options=ChatOptions()) # type: ignore - content_webp = response_webp.messages[0].contents[0] - assert isinstance(content_webp, DataContent) - assert content_webp.media_type == "image/webp" - assert "data:image/webp;base64," in content_webp.uri + assert isinstance(first_response, AgentRunResponse) + assert first_response.text is not None + # Should use the run-level weather tool (call count should be 1) + assert call_count == 1 + assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) + # Second run - run-level tool should NOT persist (key isolation test) + second_response = await agent.run("What's the weather like in Miami?") -def test_create_response_content_image_generation_fallback(): - """Test image generation with invalid base64 falls back to PNG.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + assert isinstance(second_response, AgentRunResponse) + assert second_response.text is not None + # Should NOT use the weather tool since it was only run-level in previous call + # Call count should still be 1 (no additional calls) + assert call_count == 1 - # Create a mock response with invalid base64 - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 - # Mock image generation output item with unrecognized format (should fall back to PNG) - unrecognized_data = b"UNKNOWN_FORMAT" + b"some_binary_data" - unrecognized_base64 = base64.b64encode(unrecognized_data).decode() - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = unrecognized_base64 +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_chat_options_run_level() -> None: + """Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant.", + ) as agent: + response = await agent.run( + "Provide a brief, helpful response about why the sky blue is.", + max_tokens=600, + model_id="gpt-4o", + user="comprehensive-test-user", + tools=[get_weather], + tool_choice="auto", + ) - mock_response.output = [mock_item] + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore - # Verify it falls back to PNG format for unrecognized binary data - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.media_type == "image/png" - assert f"data:image/png;base64,{unrecognized_base64}" == content.uri +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_chat_options_agent_level() -> None: + """Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent.""" + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant.", + max_tokens=100, + temperature=0.7, + top_p=0.9, + seed=123, + user="comprehensive-test-user", + tools=[get_weather], + tool_choice="auto", + ) as agent: + response = await agent.run( + "Provide a brief, helpful response.", + ) + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 -def test_prepare_options_store_parameter_handling() -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test message")] - test_conversation_id = "test-conversation-123" - chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is True - assert options["previous_response_id"] == test_conversation_id +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_hosted_mcp_tool() -> None: + """Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP.""" - chat_options = ChatOptions(store=False, conversation_id="") - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False + mcp_tool = HostedMCPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + description="A Microsoft Learn MCP server for documentation questions", + approval_mode="never_require", + ) - chat_options = ChatOptions(store=None, conversation_id=None) - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False - assert "previous_response_id" not in options + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that can help with microsoft documentation questions.", + tools=[mcp_tool], + ) as agent: + response = await agent.run( + "How to create an Azure storage account using az cli?", + max_tokens=200, + ) - chat_options = ChatOptions() - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False - assert "previous_response_id" not in options + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + # Should contain Azure-related content since it's asking about Azure CLI + assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) -def test_openai_responses_client_with_callable_api_key() -> None: - """Test OpenAIResponsesClient initialization with callable API key.""" +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_local_mcp_tool() -> None: + """Integration test for MCPStreamableHTTPTool with OpenAI Response Agent using Microsoft Learn MCP.""" - async def get_api_key() -> str: - return "test-api-key-123" + mcp_tool = MCPStreamableHTTPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ) - client = OpenAIResponsesClient(model_id="gpt-4o", api_key=get_api_key) + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that can help with microsoft documentation questions.", + tools=[mcp_tool], + ) as agent: + response = await agent.run( + "How to create an Azure storage account using az cli?", + max_tokens=200, + ) - # Verify client was created successfully - assert client.model_id == "gpt-4o" - # OpenAI SDK now manages callable API keys internally - assert client.client is not None + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + # Should contain Azure-related content since it's asking about Azure CLI + assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 3bda2fcaad8..77fd969f12e 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -111,6 +111,10 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index] assert "messages" in chat_store_state, "Message store state should include messages" + # Verify checkpoint contains pending requests from agents and responses to be sent + assert "pending_agent_requests" in executor_state + assert "pending_responses_to_agent" in executor_state + # Create a new agent and executor for restoration # This simulates starting from a fresh state and restoring from checkpoint restored_agent = _CountingAgent(id="test_agent", name="TestAgent") diff --git a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py index 8124f6253d4..a7849120b09 100644 --- a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py +++ b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py @@ -5,19 +5,32 @@ from collections.abc import AsyncIterable from typing import Any +from typing_extensions import Never + from agent_framework import ( AgentExecutor, + AgentExecutorResponse, AgentRunResponse, AgentRunResponseUpdate, AgentRunUpdateEvent, AgentThread, BaseAgent, + ChatAgent, ChatMessage, + ChatResponse, + ChatResponseUpdate, + FunctionApprovalRequestContent, FunctionCallContent, FunctionResultContent, + RequestInfoEvent, Role, TextContent, WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + ai_function, + executor, + use_function_invocation, ) @@ -120,3 +133,235 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None: assert events[3].data is not None assert isinstance(events[3].data.contents[0], TextContent) assert "sunny" in events[3].data.contents[0].text + + +@ai_function(approval_mode="always_require") +def mock_tool_requiring_approval(query: str) -> str: + """Mock tool that requires approval before execution.""" + return f"Executed tool with query: {query}" + + +@use_function_invocation +class MockChatClient: + """Simple implementation of a chat client.""" + + def __init__(self, parallel_request: bool = False) -> None: + self.additional_properties: dict[str, Any] = {} + self._iteration: int = 0 + self._parallel_request: bool = parallel_request + + async def get_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> ChatResponse: + if self._iteration == 0: + if self._parallel_request: + response = ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + FunctionCallContent( + call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + ], + ) + ) + else: + response = ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ) + ], + ) + ) + else: + response = ChatResponse(messages=ChatMessage(role="assistant", text="Tool executed successfully.")) + + self._iteration += 1 + return response + + async def get_streaming_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + if self._iteration == 0: + if self._parallel_request: + yield ChatResponseUpdate( + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + FunctionCallContent( + call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + ], + role="assistant", + ) + else: + yield ChatResponseUpdate( + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ) + ], + role="assistant", + ) + else: + yield ChatResponseUpdate(text=TextContent(text="Tool executed "), role="assistant") + yield ChatResponseUpdate(contents=[TextContent(text="successfully.")], role="assistant") + + self._iteration += 1 + + +@executor(id="test_executor") +async def test_executor(agent_executor_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(agent_executor_response.agent_run_response.text) + + +async def test_agent_executor_tool_call_with_approval() -> None: + """Test that AgentExecutor handles tool calls requiring approval.""" + # Arrange + agent = ChatAgent( + chat_client=MockChatClient(), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + events = await workflow.run("Invoke tool requiring approval") + + # Assert + assert len(events.get_request_info_events()) == 1 + approval_request = events.get_request_info_events()[0] + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + events = await workflow.send_responses({approval_request.request_id: approval_request.data.create_response(True)}) + + # Assert + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." + + +async def test_agent_executor_tool_call_with_approval_streaming() -> None: + """Test that AgentExecutor handles tool calls requiring approval in streaming mode.""" + # Arrange + agent = ChatAgent( + chat_client=MockChatClient(), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + request_info_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("Invoke tool requiring approval"): + if isinstance(event, RequestInfoEvent): + request_info_events.append(event) + + # Assert + assert len(request_info_events) == 1 + approval_request = request_info_events[0] + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + output: str | None = None + async for event in workflow.send_responses_streaming({ + approval_request.request_id: approval_request.data.create_response(True) + }): + if isinstance(event, WorkflowOutputEvent): + output = event.data + + # Assert + assert output is not None + assert output == "Tool executed successfully." + + +async def test_agent_executor_parallel_tool_call_with_approval() -> None: + """Test that AgentExecutor handles parallel tool calls requiring approval.""" + # Arrange + agent = ChatAgent( + chat_client=MockChatClient(parallel_request=True), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + events = await workflow.run("Invoke tool requiring approval") + + # Assert + assert len(events.get_request_info_events()) == 2 + for approval_request in events.get_request_info_events(): + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + responses = { + approval_request.request_id: approval_request.data.create_response(True) # type: ignore + for approval_request in events.get_request_info_events() + } + events = await workflow.send_responses(responses) + + # Assert + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." + + +async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> None: + """Test that AgentExecutor handles parallel tool calls requiring approval in streaming mode.""" + # Arrange + agent = ChatAgent( + chat_client=MockChatClient(parallel_request=True), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + request_info_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("Invoke tool requiring approval"): + if isinstance(event, RequestInfoEvent): + request_info_events.append(event) + + # Assert + assert len(request_info_events) == 2 + for approval_request in request_info_events: + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + responses = { + approval_request.request_id: approval_request.data.create_response(True) # type: ignore + for approval_request in request_info_events + } + + output: str | None = None + async for event in workflow.send_responses_streaming(responses): + if isinstance(event, WorkflowOutputEvent): + output = event.data + + # Assert + assert output is not None + assert output == "Tool executed successfully." diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 44a6403c6fb..a799fb6f730 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -23,7 +23,7 @@ WorkflowOutputEvent, ) from agent_framework._mcp import MCPTool -from agent_framework._workflows._handoff import _clone_chat_agent +from agent_framework._workflows._handoff import _clone_chat_agent # type: ignore[reportPrivateUsage] @dataclass @@ -392,12 +392,218 @@ def sample_function() -> str: ) assert hasattr(original_agent, "_local_mcp_tools") - assert len(original_agent._local_mcp_tools) == 1 - assert original_agent._local_mcp_tools[0] == mock_mcp_tool + assert len(original_agent._local_mcp_tools) == 1 # type: ignore[reportPrivateUsage] + assert original_agent._local_mcp_tools[0] == mock_mcp_tool # type: ignore[reportPrivateUsage] cloned_agent = _clone_chat_agent(original_agent) assert hasattr(cloned_agent, "_local_mcp_tools") - assert len(cloned_agent._local_mcp_tools) == 1 - assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool + assert len(cloned_agent._local_mcp_tools) == 1 # type: ignore[reportPrivateUsage] + assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool # type: ignore[reportPrivateUsage] + assert cloned_agent.chat_options.tools is not None assert len(cloned_agent.chat_options.tools) == 1 + + +async def test_return_to_previous_routing(): + """Test that return-to-previous routes back to the current specialist handling the conversation.""" + triage = _RecordingAgent(name="triage", handoff_to="specialist_a") + specialist_a = _RecordingAgent(name="specialist_a", handoff_to="specialist_b") + specialist_b = _RecordingAgent(name="specialist_b") + + workflow = ( + HandoffBuilder(participants=[triage, specialist_a, specialist_b]) + .set_coordinator(triage) + .add_handoff(triage, [specialist_a, specialist_b]) + .add_handoff(specialist_a, specialist_b) + .enable_return_to_previous(True) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 4) + .build() + ) + + # Start conversation - triage hands off to specialist_a + events = await _drain(workflow.run_stream("Initial request")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + assert len(specialist_a.calls) > 0 + + # Specialist_a should have been called with initial request + initial_specialist_a_calls = len(specialist_a.calls) + + # Second user message - specialist_a hands off to specialist_b + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Need more help"})) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Specialist_b should have been called + assert len(specialist_b.calls) > 0 + initial_specialist_b_calls = len(specialist_b.calls) + + # Third user message - with return_to_previous, should route back to specialist_b (current agent) + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"})) + third_requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + + # Specialist_b should have been called again (return-to-previous routes to current agent) + assert len(specialist_b.calls) > initial_specialist_b_calls, ( + "Specialist B should be called again due to return-to-previous routing to current agent" + ) + + # Specialist_a should NOT be called again (it's no longer the current agent) + assert len(specialist_a.calls) == initial_specialist_a_calls, ( + "Specialist A should not be called again - specialist_b is the current agent" + ) + + # Triage should only have been called once at the start + assert len(triage.calls) == 1, "Triage should only be called once (initial routing)" + + # Verify awaiting_agent_id is set to specialist_b (the agent that just responded) + if third_requests: + user_input_req = third_requests[-1].data + assert isinstance(user_input_req, HandoffUserInputRequest) + assert user_input_req.awaiting_agent_id == "specialist_b", ( + f"Expected awaiting_agent_id 'specialist_b' but got '{user_input_req.awaiting_agent_id}'" + ) + + +async def test_return_to_previous_disabled_routes_to_coordinator(): + """Test that with return-to-previous disabled, routing goes back to coordinator.""" + triage = _RecordingAgent(name="triage", handoff_to="specialist_a") + specialist_a = _RecordingAgent(name="specialist_a", handoff_to="specialist_b") + specialist_b = _RecordingAgent(name="specialist_b") + + workflow = ( + HandoffBuilder(participants=[triage, specialist_a, specialist_b]) + .set_coordinator(triage) + .add_handoff(triage, [specialist_a, specialist_b]) + .add_handoff(specialist_a, specialist_b) + .enable_return_to_previous(False) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3) + .build() + ) + + # Start conversation - triage hands off to specialist_a + events = await _drain(workflow.run_stream("Initial request")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + assert len(triage.calls) == 1 + + # Second user message - specialist_a hands off to specialist_b + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Need more help"})) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Third user message - without return_to_previous, should route back to triage + await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"})) + + # Triage should have been called twice total: initial + after specialist_b responds + assert len(triage.calls) == 2, "Triage should be called twice (initial + default routing to coordinator)" + + +async def test_return_to_previous_enabled(): + """Verify that enable_return_to_previous() keeps control with the current specialist.""" + triage = _RecordingAgent(name="triage", handoff_to="specialist_a") + specialist_a = _RecordingAgent(name="specialist_a") + specialist_b = _RecordingAgent(name="specialist_b") + + workflow = ( + HandoffBuilder(participants=[triage, specialist_a, specialist_b]) + .set_coordinator("triage") + .enable_return_to_previous(True) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3) + .build() + ) + + # Start conversation - triage hands off to specialist_a + events = await _drain(workflow.run_stream("Initial request")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + assert len(triage.calls) == 1 + assert len(specialist_a.calls) == 1 + + # Second user message - with return_to_previous, should route to specialist_a (not triage) + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"})) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Triage should only have been called once (initial) - specialist_a handles follow-up + assert len(triage.calls) == 1, "Triage should only be called once (initial)" + assert len(specialist_a.calls) == 2, "Specialist A should handle follow-up with return_to_previous enabled" + + +async def test_tool_choice_preserved_from_agent_config(): + """Verify that agent-level tool_choice configuration is preserved and not overridden.""" + from unittest.mock import AsyncMock + + from agent_framework import ChatResponse, ToolMode + + # Create a mock chat client that records the tool_choice used + recorded_tool_choices: list[Any] = [] + + async def mock_get_response(messages: Any, **kwargs: Any) -> ChatResponse: + chat_options = kwargs.get("chat_options") + if chat_options: + recorded_tool_choices.append(chat_options.tool_choice) + return ChatResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text="Response")], + response_id="test_response", + ) + + mock_client = MagicMock() + mock_client.get_response = AsyncMock(side_effect=mock_get_response) + + # Create agent with specific tool_choice configuration + agent = ChatAgent( + chat_client=mock_client, + name="test_agent", + tool_choice=ToolMode(mode="required"), # type: ignore[arg-type] + ) + + # Run the agent + await agent.run("Test message") + + # Verify tool_choice was preserved + assert len(recorded_tool_choices) > 0, "No tool_choice recorded" + last_tool_choice = recorded_tool_choices[-1] + assert last_tool_choice is not None, "tool_choice should not be None" + assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}" + + +async def test_return_to_previous_state_serialization(): + """Test that return_to_previous state is properly serialized/deserialized for checkpointing.""" + from agent_framework._workflows._handoff import _HandoffCoordinator # type: ignore[reportPrivateUsage] + + # Create a coordinator with return_to_previous enabled + coordinator = _HandoffCoordinator( + starting_agent_id="triage", + specialist_ids={"specialist_a": "specialist_a", "specialist_b": "specialist_b"}, + input_gateway_id="gateway", + termination_condition=lambda conv: False, + id="test-coordinator", + return_to_previous=True, + ) + + # Set the current agent (simulating a handoff scenario) + coordinator._current_agent_id = "specialist_a" # type: ignore[reportPrivateUsage] + + # Snapshot the state + state = coordinator.snapshot_state() + + # Verify pattern metadata includes current_agent_id + assert "metadata" in state + assert "current_agent_id" in state["metadata"] + assert state["metadata"]["current_agent_id"] == "specialist_a" + + # Create a new coordinator and restore state + coordinator2 = _HandoffCoordinator( + starting_agent_id="triage", + specialist_ids={"specialist_a": "specialist_a", "specialist_b": "specialist_b"}, + input_gateway_id="gateway", + termination_condition=lambda conv: False, + id="test-coordinator", + return_to_previous=True, + ) + + # Restore state + coordinator2.restore_state(state) + + # Verify current_agent_id was restored + assert coordinator2._current_agent_id == "specialist_a", "Current agent should be restored from checkpoint" # type: ignore[reportPrivateUsage] diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 66bf3431d03..d8f01d95273 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -397,6 +397,7 @@ async def get_meta() -> MetaResponse: ui_mode=self.mode, # type: ignore[arg-type] version=__version__, framework="agent_framework", + runtime="python", # Python DevUI backend capabilities={ "tracing": os.getenv("ENABLE_OTEL") == "true", "openai_proxy": openai_executor.is_configured, diff --git a/python/packages/devui/agent_framework_devui/models/_openai_custom.py b/python/packages/devui/agent_framework_devui/models/_openai_custom.py index b291080a013..f82ef90b728 100644 --- a/python/packages/devui/agent_framework_devui/models/_openai_custom.py +++ b/python/packages/devui/agent_framework_devui/models/_openai_custom.py @@ -386,6 +386,9 @@ class MetaResponse(BaseModel): framework: str = "agent_framework" """Backend framework identifier.""" + runtime: Literal["python", "dotnet"] = "python" + """Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability.""" + capabilities: dict[str, bool] = {} """Server capabilities (e.g., tracing, openai_proxy).""" diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.css b/python/packages/devui/agent_framework_devui/ui/assets/index.css index 42d917c3ab3..d44bb61519e 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.css +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.css @@ -1 +1 @@ -/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-1\.5{margin-left:calc(var(--spacing)*1.5)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-96{height:calc(var(--spacing)*96)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[500px\]{height:500px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-20{max-height:calc(var(--spacing)*20)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[200px\]{width:200px}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:#643fb233}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.border-green-500\/20{border-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive)70%,transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive)90%,transparent)}}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-800{color:var(--color-orange-800)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.paused{animation-play-state:paused}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-6{gap:calc(var(--spacing)*6)}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:#8b5cf633}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/30:is(.dark *){background-color:#1624564d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)30%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} +/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-1\.5{margin-left:calc(var(--spacing)*1.5)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-96{height:calc(var(--spacing)*96)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[500px\]{height:500px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-20{max-height:calc(var(--spacing)*20)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[200px\]{width:200px}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:#643fb233}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.border-green-500\/20{border-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive)70%,transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive)90%,transparent)}}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-800{color:var(--color-orange-800)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.paused{animation-play-state:paused}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-6{gap:calc(var(--spacing)*6)}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:#8b5cf633}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/30:is(.dark *){background-color:#1624564d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)30%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 1f71c0cb2b5..3744c1e10dd 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -1,4 +1,4 @@ -function pE(e,n){for(var s=0;so[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))o(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function s(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(l){if(l.ep)return;l.ep=!0;const c=s(l);fetch(l.href,c)}})();function up(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Hm={exports:{}},Oi={};/** +function gE(e,n){for(var s=0;so[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))o(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function s(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(l){if(l.ep)return;l.ep=!0;const c=s(l);fetch(l.href,c)}})();function dp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var $m={exports:{}},Oi={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ function pE(e,n){for(var s=0;s>>1,T=A[U];if(0>>1;Ul(W,$))sel(ce,W)?(A[U]=ce,A[se]=$,U=se):(A[U]=W,A[K]=$,U=K);else if(sel(ce,$))A[U]=ce,A[se]=$,U=se;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,_=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,j=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,B=-1;function X(){return _?!0:!(e.unstable_now()-BA&&X());){var U=v.callback;if(typeof U=="function"){v.callback=null,y=v.priorityLevel;var T=U(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var P=s(p);P!==null&&V(R,P.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof j=="function")G=function(){j(Q)};else if(typeof MessageChannel<"u"){var re=new MessageChannel,L=re.port2;re.port1.onmessage=Q,G=function(){L.postMessage(null)}}else G=function(){E(Q,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125U?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-U))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Um)),Um}var Zy;function bE(){return Zy||(Zy=1,Pm.exports=vE()),Pm.exports}var Vm={exports:{}},Gt={};/** + */var Zy;function bE(){return Zy||(Zy=1,(function(e){function n(A,I){var $=A.length;A.push(I);e:for(;0<$;){var P=$-1>>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,_=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,j=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return _?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof j=="function")G=function(){j(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ function pE(e,n){for(var s=0;s"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),Vm.exports=wE(),Vm.exports}/** + */var Ky;function NE(){if(Ky)return Yt;Ky=1;var e=dl();function n(m){var p="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),qm.exports=NE(),qm.exports}/** * @license React * react-dom-client.production.js * @@ -38,16 +38,16 @@ function pE(e,n){for(var s=0;sT||(t.current=U[T],U[T]=null,T--)}function W(t,r){T++,U[T]=t.current,t.current=r}var se=P(null),ce=P(null),fe=P(null),ne=P(null);function ie(t,r){switch(W(fe,r),W(ce,t),W(se,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?yy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=yy(r),t=vy(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}K(se),W(se,t)}function ve(){K(se),K(ce),K(fe)}function we(t){t.memoizedState!==null&&W(ne,t);var r=se.current,i=vy(r,t.type);r!==i&&(W(ce,t),W(se,i))}function je(t){ce.current===t&&(K(se),K(ce)),ne.current===t&&(K(ne),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Te=e.unstable_scheduleCallback,ee=e.unstable_cancelCallback,Re=e.unstable_shouldYield,Ve=e.unstable_requestPaint,We=e.unstable_now,Ot=e.unstable_getCurrentPriorityLevel,yt=e.unstable_ImmediatePriority,St=e.unstable_UserBlockingPriority,tt=e.unstable_NormalPriority,mt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,F=e.log,ge=e.unstable_setDisableYieldValue,le=null,xe=null;function me(t){if(typeof F=="function"&&ge(t),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(le,t)}catch{}}var ye=Math.clz32?Math.clz32:ot,Ee=Math.log,ze=Math.LN2;function ot(t){return t>>>=0,t===0?32:31-(Ee(t)/ze|0)|0}var Et=256,Se=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Ne(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function nt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function dt(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function qt(){var t=Et;return Et<<=1,(Et&4194048)===0&&(Et=256),t}function Fn(){var t=Se;return Se<<=1,(Se&62914560)===0&&(Se=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Cd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,te=t.hiddenUpdates;for(i=C&~i;0T||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function _e(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function je(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0)":-1h||q[u]!==te[h]){var ue=` -`+q[u].replace(" at new "," at ");return t.displayName&&ue.includes("")&&(ue=ue.replace("",t.displayName)),ue}while(1<=u&&0<=h);break}}}finally{Ha=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?hr(i):""}function Dd(t){switch(t.tag){case 26:case 27:case 5:return hr(t.type);case 16:return hr("Lazy");case 13:return hr("Suspense");case 19:return hr("SuspenseList");case 0:case 15:return $a(t.type,!1);case 11:return $a(t.type.render,!1);case 1:return $a(t.type,!0);case 31:return hr("Activity");default:return""}}function Rl(t){try{var r="";do r+=Dd(t),t=t.return;while(t);return r}catch(i){return` +`+La+t+Tl}var Ha=!1;function $a(t,r){if(!t||Ha)return"";Ha=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(r){var xe=function(){throw Error()};if(Object.defineProperty(xe.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(xe,[])}catch(oe){var se=oe}Reflect.construct(t,[],xe)}else{try{xe.call()}catch(oe){se=oe}t.call(xe.prototype)}}else{try{throw Error()}catch(oe){se=oe}(xe=t())&&typeof xe.catch=="function"&&xe.catch(function(){})}}catch(oe){if(oe&&se&&typeof oe.stack=="string")return[oe.stack,se.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var h=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");h&&h.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var x=u.DetermineComponentFrameRoot(),C=x[0],O=x[1];if(C&&O){var q=C.split(` +`),ee=O.split(` +`);for(h=u=0;uh||q[u]!==ee[h]){var fe=` +`+q[u].replace(" at new "," at ");return t.displayName&&fe.includes("")&&(fe=fe.replace("",t.displayName)),fe}while(1<=u&&0<=h);break}}}finally{Ha=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?hr(i):""}function Od(t){switch(t.tag){case 26:case 27:case 5:return hr(t.type);case 16:return hr("Lazy");case 13:return hr("Suspense");case 19:return hr("SuspenseList");case 0:case 15:return $a(t.type,!1);case 11:return $a(t.type.render,!1);case 1:return $a(t.type,!0);case 31:return hr("Activity");default:return""}}function Rl(t){try{var r="";do r+=Od(t),t=t.return;while(t);return r}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function en(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function Dl(t){var r=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(r==="checkbox"||r==="radio")}function Od(t){var r=Dl(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,r),u=""+t[r];if(!t.hasOwnProperty(r)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var h=i.get,x=i.set;return Object.defineProperty(t,r,{configurable:!0,get:function(){return h.call(this)},set:function(C){u=""+C,x.call(this,C)}}),Object.defineProperty(t,r,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(C){u=""+C},stopTracking:function(){t._valueTracker=null,delete t[r]}}}}function vo(t){t._valueTracker||(t._valueTracker=Od(t))}function Ba(t){if(!t)return!1;var r=t._valueTracker;if(!r)return!0;var i=r.getValue(),u="";return t&&(u=Dl(t)?t.checked?"true":"false":t.value),t=u,t!==i?(r.setValue(t),!0):!1}function bo(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var zd=/[\n"\\]/g;function tn(t){return t.replace(zd,function(r){return"\\"+r.charCodeAt(0).toString(16)+" "})}function Rs(t,r,i,u,h,x,C,O){t.name="",C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"?t.type=C:t.removeAttribute("type"),r!=null?C==="number"?(r===0&&t.value===""||t.value!=r)&&(t.value=""+en(r)):t.value!==""+en(r)&&(t.value=""+en(r)):C!=="submit"&&C!=="reset"||t.removeAttribute("value"),r!=null?Pa(t,C,en(r)):i!=null?Pa(t,C,en(i)):u!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+en(O):t.removeAttribute("name")}function Ol(t,r,i,u,h,x,C,O){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),r!=null||i!=null){if(!(x!=="submit"&&x!=="reset"||r!=null))return;i=i!=null?""+en(i):"",r=r!=null?""+en(r):i,O||r===t.value||(t.value=r),t.defaultValue=r}u=u??h,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"&&(t.name=C)}function Pa(t,r,i){r==="number"&&bo(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function pr(t,r,i,u){if(t=t.options,r){r={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Bd=!1;if(gr)try{var Va={};Object.defineProperty(Va,"passive",{get:function(){Bd=!0}}),window.addEventListener("test",Va,Va),window.removeEventListener("test",Va,Va)}catch{Bd=!1}var Vr=null,Pd=null,Il=null;function Ng(){if(Il)return Il;var t,r=Pd,i=r.length,u,h="value"in Vr?Vr.value:Vr.textContent,x=h.length;for(t=0;t=Ya),kg=" ",Ag=!1;function Mg(t,r){switch(t){case"keyup":return $j.indexOf(r.keyCode)!==-1;case"keydown":return r.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Tg(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var jo=!1;function Pj(t,r){switch(t){case"compositionend":return Tg(r);case"keypress":return r.which!==32?null:(Ag=!0,kg);case"textInput":return t=r.data,t===kg&&Ag?null:t;default:return null}}function Uj(t,r){if(jo)return t==="compositionend"||!Yd&&Mg(t,r)?(t=Ng(),Il=Pd=Vr=null,jo=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(r.ctrlKey||r.altKey||r.metaKey)||r.ctrlKey&&r.altKey){if(r.char&&1=r)return{node:i,offset:r-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=$g(i)}}function Pg(t,r){return t&&r?t===r?!0:t&&t.nodeType===3?!1:r&&r.nodeType===3?Pg(t,r.parentNode):"contains"in t?t.contains(r):t.compareDocumentPosition?!!(t.compareDocumentPosition(r)&16):!1:!1}function Ug(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var r=bo(t.document);r instanceof t.HTMLIFrameElement;){try{var i=typeof r.contentWindow.location.href=="string"}catch{i=!1}if(i)t=r.contentWindow;else break;r=bo(t.document)}return r}function Zd(t){var r=t&&t.nodeName&&t.nodeName.toLowerCase();return r&&(r==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||r==="textarea"||t.contentEditable==="true")}var Wj=gr&&"documentMode"in document&&11>=document.documentMode,_o=null,Wd=null,Wa=null,Kd=!1;function Vg(t,r,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Kd||_o==null||_o!==bo(u)||(u=_o,"selectionStart"in u&&Zd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Wa&&Za(Wa,u)||(Wa=u,u=Ec(Wd,"onSelect"),0>=C,h-=C,yr=1<<32-ye(r)+h|i<x?x:8;var C=A.T,O={};A.T=O,Lf(t,!1,r,i);try{var q=h(),te=A.S;if(te!==null&&te(O,q),q!==null&&typeof q=="object"&&typeof q.then=="function"){var ue=o_(q,u);di(t,r,ue,mn(t))}else di(t,r,u,mn(t))}catch(he){di(t,r,{then:function(){},status:"rejected",reason:he},mn())}finally{I.p=x,A.T=C}}function u_(){}function zf(t,r,i,u){if(t.tag!==5)throw Error(o(476));var h=qx(t).queue;Vx(t,h,r,$,i===null?u_:function(){return Fx(t),i(u)})}function qx(t){var r=t.memoizedState;if(r!==null)return r;r={memoizedState:$,baseState:$,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:$},next:null};var i={};return r.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:i},next:null},t.memoizedState=r,t=t.alternate,t!==null&&(t.memoizedState=r),r}function Fx(t){var r=qx(t).next.queue;di(t,r,{},mn())}function If(){return Yt(Ai)}function Yx(){return At().memoizedState}function Gx(){return At().memoizedState}function d_(t){for(var r=t.return;r!==null;){switch(r.tag){case 24:case 3:var i=mn();t=Yr(i);var u=Gr(r,t,i);u!==null&&(hn(u,r,i),oi(u,r,i)),r={cache:ff()},t.payload=r;return}r=r.return}}function f_(t,r,i){var u=mn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},ac(t)?Zx(r,i):(i=tf(t,r,i,u),i!==null&&(hn(i,t,u),Wx(i,r,u)))}function Xx(t,r,i){var u=mn();di(t,r,i,u)}function di(t,r,i,u){var h={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(ac(t))Zx(r,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=r.lastRenderedReducer,x!==null))try{var C=r.lastRenderedState,O=x(C,i);if(h.hasEagerState=!0,h.eagerState=O,ln(O,C))return Vl(t,r,h,0),ht===null&&Ul(),!1}catch{}finally{}if(i=tf(t,r,h,u),i!==null)return hn(i,t,u),Wx(i,r,u),!0}return!1}function Lf(t,r,i,u){if(u={lane:2,revertLane:pm(),action:u,hasEagerState:!1,eagerState:null,next:null},ac(t)){if(r)throw Error(o(479))}else r=tf(t,i,u,2),r!==null&&hn(r,t,2)}function ac(t){var r=t.alternate;return t===qe||r!==null&&r===qe}function Zx(t,r){zo=ec=!0;var i=t.pending;i===null?r.next=r:(r.next=i.next,i.next=r),t.pending=r}function Wx(t,r,i){if((i&4194048)!==0){var u=r.lanes;u&=t.pendingLanes,i|=u,r.lanes=i,Ta(t,i)}}var ic={readContext:Yt,use:nc,useCallback:jt,useContext:jt,useEffect:jt,useImperativeHandle:jt,useLayoutEffect:jt,useInsertionEffect:jt,useMemo:jt,useReducer:jt,useRef:jt,useState:jt,useDebugValue:jt,useDeferredValue:jt,useTransition:jt,useSyncExternalStore:jt,useId:jt,useHostTransitionStatus:jt,useFormState:jt,useActionState:jt,useOptimistic:jt,useMemoCache:jt,useCacheRefresh:jt},Kx={readContext:Yt,use:nc,useCallback:function(t,r){return rn().memoizedState=[t,r===void 0?null:r],t},useContext:Yt,useEffect:Ox,useImperativeHandle:function(t,r,i){i=i!=null?i.concat([t]):null,oc(4194308,4,Hx.bind(null,r,t),i)},useLayoutEffect:function(t,r){return oc(4194308,4,t,r)},useInsertionEffect:function(t,r){oc(4,2,t,r)},useMemo:function(t,r){var i=rn();r=r===void 0?null:r;var u=t();if(qs){me(!0);try{t()}finally{me(!1)}}return i.memoizedState=[u,r],u},useReducer:function(t,r,i){var u=rn();if(i!==void 0){var h=i(r);if(qs){me(!0);try{i(r)}finally{me(!1)}}}else h=r;return u.memoizedState=u.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},u.queue=t,t=t.dispatch=f_.bind(null,qe,t),[u.memoizedState,t]},useRef:function(t){var r=rn();return t={current:t},r.memoizedState=t},useState:function(t){t=Tf(t);var r=t.queue,i=Xx.bind(null,qe,r);return r.dispatch=i,[t.memoizedState,i]},useDebugValue:Df,useDeferredValue:function(t,r){var i=rn();return Of(i,t,r)},useTransition:function(){var t=Tf(!1);return t=Vx.bind(null,qe,t.queue,!0,!1),rn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,r,i){var u=qe,h=rn();if(st){if(i===void 0)throw Error(o(407));i=i()}else{if(i=r(),ht===null)throw Error(o(349));(Qe&124)!==0||yx(u,r,i)}h.memoizedState=i;var x={value:i,getSnapshot:r};return h.queue=x,Ox(bx.bind(null,u,x,t),[t]),u.flags|=2048,Lo(9,sc(),vx.bind(null,u,x,i,r),null),i},useId:function(){var t=rn(),r=ht.identifierPrefix;if(st){var i=vr,u=yr;i=(u&~(1<<32-ye(u)-1)).toString(32)+i,r="«"+r+"R"+i,i=tc++,0$e?(Ht=Oe,Oe=null):Ht=Oe.sibling;var rt=oe(Z,Oe,J[$e],de);if(rt===null){Oe===null&&(Oe=Ht);break}t&&Oe&&rt.alternate===null&&r(Z,Oe),Y=x(rt,Y,$e),Ye===null?Ce=rt:Ye.sibling=rt,Ye=rt,Oe=Ht}if($e===J.length)return i(Z,Oe),st&&Hs(Z,$e),Ce;if(Oe===null){for(;$e$e?(Ht=Oe,Oe=null):Ht=Oe.sibling;var us=oe(Z,Oe,rt.value,de);if(us===null){Oe===null&&(Oe=Ht);break}t&&Oe&&us.alternate===null&&r(Z,Oe),Y=x(us,Y,$e),Ye===null?Ce=us:Ye.sibling=us,Ye=us,Oe=Ht}if(rt.done)return i(Z,Oe),st&&Hs(Z,$e),Ce;if(Oe===null){for(;!rt.done;$e++,rt=J.next())rt=he(Z,rt.value,de),rt!==null&&(Y=x(rt,Y,$e),Ye===null?Ce=rt:Ye.sibling=rt,Ye=rt);return st&&Hs(Z,$e),Ce}for(Oe=u(Oe);!rt.done;$e++,rt=J.next())rt=ae(Oe,Z,$e,rt.value,de),rt!==null&&(t&&rt.alternate!==null&&Oe.delete(rt.key===null?$e:rt.key),Y=x(rt,Y,$e),Ye===null?Ce=rt:Ye.sibling=rt,Ye=rt);return t&&Oe.forEach(function(hE){return r(Z,hE)}),st&&Hs(Z,$e),Ce}function ut(Z,Y,J,de){if(typeof J=="object"&&J!==null&&J.type===S&&J.key===null&&(J=J.props.children),typeof J=="object"&&J!==null){switch(J.$$typeof){case y:e:{for(var Ce=J.key;Y!==null;){if(Y.key===Ce){if(Ce=J.type,Ce===S){if(Y.tag===7){i(Z,Y.sibling),de=h(Y,J.props.children),de.return=Z,Z=de;break e}}else if(Y.elementType===Ce||typeof Ce=="object"&&Ce!==null&&Ce.$$typeof===H&&Jx(Ce)===Y.type){i(Z,Y.sibling),de=h(Y,J.props),mi(de,J),de.return=Z,Z=de;break e}i(Z,Y);break}else r(Z,Y);Y=Y.sibling}J.type===S?(de=Is(J.props.children,Z.mode,de,J.key),de.return=Z,Z=de):(de=Fl(J.type,J.key,J.props,null,Z.mode,de),mi(de,J),de.return=Z,Z=de)}return C(Z);case b:e:{for(Ce=J.key;Y!==null;){if(Y.key===Ce)if(Y.tag===4&&Y.stateNode.containerInfo===J.containerInfo&&Y.stateNode.implementation===J.implementation){i(Z,Y.sibling),de=h(Y,J.children||[]),de.return=Z,Z=de;break e}else{i(Z,Y);break}else r(Z,Y);Y=Y.sibling}de=sf(J,Z.mode,de),de.return=Z,Z=de}return C(Z);case H:return Ce=J._init,J=Ce(J._payload),ut(Z,Y,J,de)}if(V(J))return Be(Z,Y,J,de);if(G(J)){if(Ce=G(J),typeof Ce!="function")throw Error(o(150));return J=Ce.call(J),Le(Z,Y,J,de)}if(typeof J.then=="function")return ut(Z,Y,lc(J),de);if(J.$$typeof===j)return ut(Z,Y,Zl(Z,J),de);cc(Z,J)}return typeof J=="string"&&J!==""||typeof J=="number"||typeof J=="bigint"?(J=""+J,Y!==null&&Y.tag===6?(i(Z,Y.sibling),de=h(Y,J),de.return=Z,Z=de):(i(Z,Y),de=rf(J,Z.mode,de),de.return=Z,Z=de),C(Z)):i(Z,Y)}return function(Z,Y,J,de){try{fi=0;var Ce=ut(Z,Y,J,de);return Ho=null,Ce}catch(Oe){if(Oe===ri||Oe===Kl)throw Oe;var Ye=cn(29,Oe,null,Z.mode);return Ye.lanes=de,Ye.return=Z,Ye}finally{}}}var $o=e0(!0),t0=e0(!1),En=P(null),Xn=null;function Zr(t){var r=t.alternate;W(Rt,Rt.current&1),W(En,t),Xn===null&&(r===null||Oo.current!==null||r.memoizedState!==null)&&(Xn=t)}function n0(t){if(t.tag===22){if(W(Rt,Rt.current),W(En,t),Xn===null){var r=t.alternate;r!==null&&r.memoizedState!==null&&(Xn=t)}}else Wr()}function Wr(){W(Rt,Rt.current),W(En,En.current)}function Sr(t){K(En),Xn===t&&(Xn=null),K(Rt)}var Rt=P(0);function uc(t){for(var r=t;r!==null;){if(r.tag===13){var i=r.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||Cm(i)))return r}else if(r.tag===19&&r.memoizedProps.revealOrder!==void 0){if((r.flags&128)!==0)return r}else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===t)break;for(;r.sibling===null;){if(r.return===null||r.return===t)return null;r=r.return}r.sibling.return=r.return,r=r.sibling}return null}function Hf(t,r,i,u){r=t.memoizedState,i=i(u,r),i=i==null?r:g({},r,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var $f={enqueueSetState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueReplaceState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.tag=1,h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueForceUpdate:function(t,r){t=t._reactInternals;var i=mn(),u=Yr(i);u.tag=2,r!=null&&(u.callback=r),r=Gr(t,u,i),r!==null&&(hn(r,t,i),oi(r,t,i))}};function r0(t,r,i,u,h,x,C){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,x,C):r.prototype&&r.prototype.isPureReactComponent?!Za(i,u)||!Za(h,x):!0}function s0(t,r,i,u){t=r.state,typeof r.componentWillReceiveProps=="function"&&r.componentWillReceiveProps(i,u),typeof r.UNSAFE_componentWillReceiveProps=="function"&&r.UNSAFE_componentWillReceiveProps(i,u),r.state!==t&&$f.enqueueReplaceState(r,r.state,null)}function Fs(t,r){var i=r;if("ref"in r){i={};for(var u in r)u!=="ref"&&(i[u]=r[u])}if(t=t.defaultProps){i===r&&(i=g({},i));for(var h in t)i[h]===void 0&&(i[h]=t[h])}return i}var dc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var r=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(r))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function o0(t){dc(t)}function a0(t){console.error(t)}function i0(t){dc(t)}function fc(t,r){try{var i=t.onUncaughtError;i(r.value,{componentStack:r.stack})}catch(u){setTimeout(function(){throw u})}}function l0(t,r,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:r.tag===1?r.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function Bf(t,r,i){return i=Yr(i),i.tag=3,i.payload={element:null},i.callback=function(){fc(t,r)},i}function c0(t){return t=Yr(t),t.tag=3,t}function u0(t,r,i,u){var h=i.type.getDerivedStateFromError;if(typeof h=="function"){var x=u.value;t.payload=function(){return h(x)},t.callback=function(){l0(r,i,u)}}var C=i.stateNode;C!==null&&typeof C.componentDidCatch=="function"&&(t.callback=function(){l0(r,i,u),typeof h!="function"&&(ns===null?ns=new Set([this]):ns.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function h_(t,r,i,u,h){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(r=i.alternate,r!==null&&ei(r,i,h,!0),i=En.current,i!==null){switch(i.tag){case 13:return Xn===null?um():i.alternate===null&&Nt===0&&(Nt=3),i.flags&=-257,i.flags|=65536,i.lanes=h,u===pf?i.flags|=16384:(r=i.updateQueue,r===null?i.updateQueue=new Set([u]):r.add(u),fm(t,u,h)),!1;case 22:return i.flags|=65536,u===pf?i.flags|=16384:(r=i.updateQueue,r===null?(r={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=r):(i=r.retryQueue,i===null?r.retryQueue=new Set([u]):i.add(u)),fm(t,u,h)),!1}throw Error(o(435,i.tag))}return fm(t,u,h),um(),!1}if(st)return r=En.current,r!==null?((r.flags&65536)===0&&(r.flags|=256),r.flags|=65536,r.lanes=h,u!==lf&&(t=Error(o(422),{cause:u}),Ja(Nn(t,i)))):(u!==lf&&(r=Error(o(423),{cause:u}),Ja(Nn(r,i))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,u=Nn(u,i),h=Bf(t.stateNode,u,h),yf(t,h),Nt!==4&&(Nt=2)),!1;var x=Error(o(520),{cause:u});if(x=Nn(x,i),bi===null?bi=[x]:bi.push(x),Nt!==4&&(Nt=2),r===null)return!0;u=Nn(u,i),i=r;do{switch(i.tag){case 3:return i.flags|=65536,t=h&-h,i.lanes|=t,t=Bf(i.stateNode,u,t),yf(i,t),!1;case 1:if(r=i.type,x=i.stateNode,(i.flags&128)===0&&(typeof r.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(ns===null||!ns.has(x))))return i.flags|=65536,h&=-h,i.lanes|=h,h=c0(h),u0(h,t,i,u),yf(i,h),!1}i=i.return}while(i!==null);return!1}var d0=Error(o(461)),It=!1;function $t(t,r,i,u){r.child=t===null?t0(r,null,i,u):$o(r,t.child,i,u)}function f0(t,r,i,u,h){i=i.render;var x=r.ref;if("ref"in u){var C={};for(var O in u)O!=="ref"&&(C[O]=u[O])}else C=u;return Us(r),u=Sf(t,r,i,C,x,h),O=jf(),t!==null&&!It?(_f(t,r,h),jr(t,r,h)):(st&&O&&of(r),r.flags|=1,$t(t,r,u,h),r.child)}function m0(t,r,i,u,h){if(t===null){var x=i.type;return typeof x=="function"&&!nf(x)&&x.defaultProps===void 0&&i.compare===null?(r.tag=15,r.type=x,h0(t,r,x,u,h)):(t=Fl(i.type,null,u,r,r.mode,h),t.ref=r.ref,t.return=r,r.child=t)}if(x=t.child,!Xf(t,h)){var C=x.memoizedProps;if(i=i.compare,i=i!==null?i:Za,i(C,u)&&t.ref===r.ref)return jr(t,r,h)}return r.flags|=1,t=xr(x,u),t.ref=r.ref,t.return=r,r.child=t}function h0(t,r,i,u,h){if(t!==null){var x=t.memoizedProps;if(Za(x,u)&&t.ref===r.ref)if(It=!1,r.pendingProps=u=x,Xf(t,h))(t.flags&131072)!==0&&(It=!0);else return r.lanes=t.lanes,jr(t,r,h)}return Pf(t,r,i,u,h)}function p0(t,r,i){var u=r.pendingProps,h=u.children,x=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((r.flags&128)!==0){if(u=x!==null?x.baseLanes|i:i,t!==null){for(h=r.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;r.childLanes=x&~u}else r.childLanes=0,r.child=null;return g0(t,r,u,i)}if((i&536870912)!==0)r.memoizedState={baseLanes:0,cachePool:null},t!==null&&Wl(r,x!==null?x.cachePool:null),x!==null?hx(r,x):bf(),n0(r);else return r.lanes=r.childLanes=536870912,g0(t,r,x!==null?x.baseLanes|i:i,i)}else x!==null?(Wl(r,x.cachePool),hx(r,x),Wr(),r.memoizedState=null):(t!==null&&Wl(r,null),bf(),Wr());return $t(t,r,h,i),r.child}function g0(t,r,i,u){var h=hf();return h=h===null?null:{parent:Tt._currentValue,pool:h},r.memoizedState={baseLanes:i,cachePool:h},t!==null&&Wl(r,null),bf(),n0(r),t!==null&&ei(t,r,u,!0),null}function mc(t,r){var i=r.ref;if(i===null)t!==null&&t.ref!==null&&(r.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(o(284));(t===null||t.ref!==i)&&(r.flags|=4194816)}}function Pf(t,r,i,u,h){return Us(r),i=Sf(t,r,i,u,void 0,h),u=jf(),t!==null&&!It?(_f(t,r,h),jr(t,r,h)):(st&&u&&of(r),r.flags|=1,$t(t,r,i,h),r.child)}function x0(t,r,i,u,h,x){return Us(r),r.updateQueue=null,i=gx(r,u,i,h),px(t),u=jf(),t!==null&&!It?(_f(t,r,x),jr(t,r,x)):(st&&u&&of(r),r.flags|=1,$t(t,r,i,x),r.child)}function y0(t,r,i,u,h){if(Us(r),r.stateNode===null){var x=Ao,C=i.contextType;typeof C=="object"&&C!==null&&(x=Yt(C)),x=new i(u,x),r.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=$f,r.stateNode=x,x._reactInternals=r,x=r.stateNode,x.props=u,x.state=r.memoizedState,x.refs={},gf(r),C=i.contextType,x.context=typeof C=="object"&&C!==null?Yt(C):Ao,x.state=r.memoizedState,C=i.getDerivedStateFromProps,typeof C=="function"&&(Hf(r,i,C,u),x.state=r.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(C=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),C!==x.state&&$f.enqueueReplaceState(x,x.state,null),ii(r,u,x,h),ai(),x.state=r.memoizedState),typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!0}else if(t===null){x=r.stateNode;var O=r.memoizedProps,q=Fs(i,O);x.props=q;var te=x.context,ue=i.contextType;C=Ao,typeof ue=="object"&&ue!==null&&(C=Yt(ue));var he=i.getDerivedStateFromProps;ue=typeof he=="function"||typeof x.getSnapshotBeforeUpdate=="function",O=r.pendingProps!==O,ue||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(O||te!==C)&&s0(r,x,u,C),Fr=!1;var oe=r.memoizedState;x.state=oe,ii(r,u,x,h),ai(),te=r.memoizedState,O||oe!==te||Fr?(typeof he=="function"&&(Hf(r,i,he,u),te=r.memoizedState),(q=Fr||r0(r,i,q,u,oe,te,C))?(ue||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(r.flags|=4194308)):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),r.memoizedProps=u,r.memoizedState=te),x.props=u,x.state=te,x.context=C,u=q):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!1)}else{x=r.stateNode,xf(t,r),C=r.memoizedProps,ue=Fs(i,C),x.props=ue,he=r.pendingProps,oe=x.context,te=i.contextType,q=Ao,typeof te=="object"&&te!==null&&(q=Yt(te)),O=i.getDerivedStateFromProps,(te=typeof O=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(C!==he||oe!==q)&&s0(r,x,u,q),Fr=!1,oe=r.memoizedState,x.state=oe,ii(r,u,x,h),ai();var ae=r.memoizedState;C!==he||oe!==ae||Fr||t!==null&&t.dependencies!==null&&Xl(t.dependencies)?(typeof O=="function"&&(Hf(r,i,O,u),ae=r.memoizedState),(ue=Fr||r0(r,i,ue,u,oe,ae,q)||t!==null&&t.dependencies!==null&&Xl(t.dependencies))?(te||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(u,ae,q),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(u,ae,q)),typeof x.componentDidUpdate=="function"&&(r.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(r.flags|=1024)):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&oe===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&oe===t.memoizedState||(r.flags|=1024),r.memoizedProps=u,r.memoizedState=ae),x.props=u,x.state=ae,x.context=q,u=ue):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&oe===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&oe===t.memoizedState||(r.flags|=1024),u=!1)}return x=u,mc(t,r),u=(r.flags&128)!==0,x||u?(x=r.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:x.render(),r.flags|=1,t!==null&&u?(r.child=$o(r,t.child,null,h),r.child=$o(r,null,i,h)):$t(t,r,i,h),r.memoizedState=x.state,t=r.child):t=jr(t,r,h),t}function v0(t,r,i,u){return Qa(),r.flags|=256,$t(t,r,i,u),r.child}var Uf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Vf(t){return{baseLanes:t,cachePool:ax()}}function qf(t,r,i){return t=t!==null?t.childLanes&~i:0,r&&(t|=Cn),t}function b0(t,r,i){var u=r.pendingProps,h=!1,x=(r.flags&128)!==0,C;if((C=x)||(C=t!==null&&t.memoizedState===null?!1:(Rt.current&2)!==0),C&&(h=!0,r.flags&=-129),C=(r.flags&32)!==0,r.flags&=-33,t===null){if(st){if(h?Zr(r):Wr(),st){var O=wt,q;if(q=O){e:{for(q=O,O=Gn;q.nodeType!==8;){if(!O){O=null;break e}if(q=zn(q.nextSibling),q===null){O=null;break e}}O=q}O!==null?(r.memoizedState={dehydrated:O,treeContext:Ls!==null?{id:yr,overflow:vr}:null,retryLane:536870912,hydrationErrors:null},q=cn(18,null,null,0),q.stateNode=O,q.return=r,r.child=q,Wt=r,wt=null,q=!0):q=!1}q||Bs(r)}if(O=r.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return Cm(O)?r.lanes=32:r.lanes=536870912,null;Sr(r)}return O=u.children,u=u.fallback,h?(Wr(),h=r.mode,O=hc({mode:"hidden",children:O},h),u=Is(u,h,i,null),O.return=r,u.return=r,O.sibling=u,r.child=O,h=r.child,h.memoizedState=Vf(i),h.childLanes=qf(t,C,i),r.memoizedState=Uf,u):(Zr(r),Ff(r,O))}if(q=t.memoizedState,q!==null&&(O=q.dehydrated,O!==null)){if(x)r.flags&256?(Zr(r),r.flags&=-257,r=Yf(t,r,i)):r.memoizedState!==null?(Wr(),r.child=t.child,r.flags|=128,r=null):(Wr(),h=u.fallback,O=r.mode,u=hc({mode:"visible",children:u.children},O),h=Is(h,O,i,null),h.flags|=2,u.return=r,h.return=r,u.sibling=h,r.child=u,$o(r,t.child,null,i),u=r.child,u.memoizedState=Vf(i),u.childLanes=qf(t,C,i),r.memoizedState=Uf,r=h);else if(Zr(r),Cm(O)){if(C=O.nextSibling&&O.nextSibling.dataset,C)var te=C.dgst;C=te,u=Error(o(419)),u.stack="",u.digest=C,Ja({value:u,source:null,stack:null}),r=Yf(t,r,i)}else if(It||ei(t,r,i,!1),C=(i&t.childLanes)!==0,It||C){if(C=ht,C!==null&&(u=i&-i,u=(u&42)!==0?1:Ra(u),u=(u&(C.suspendedLanes|i))!==0?0:u,u!==0&&u!==q.retryLane))throw q.retryLane=u,ko(t,u),hn(C,t,u),d0;O.data==="$?"||um(),r=Yf(t,r,i)}else O.data==="$?"?(r.flags|=192,r.child=t.child,r=null):(t=q.treeContext,wt=zn(O.nextSibling),Wt=r,st=!0,$s=null,Gn=!1,t!==null&&(jn[_n++]=yr,jn[_n++]=vr,jn[_n++]=Ls,yr=t.id,vr=t.overflow,Ls=r),r=Ff(r,u.children),r.flags|=4096);return r}return h?(Wr(),h=u.fallback,O=r.mode,q=t.child,te=q.sibling,u=xr(q,{mode:"hidden",children:u.children}),u.subtreeFlags=q.subtreeFlags&65011712,te!==null?h=xr(te,h):(h=Is(h,O,i,null),h.flags|=2),h.return=r,u.return=r,u.sibling=h,r.child=u,u=h,h=r.child,O=t.child.memoizedState,O===null?O=Vf(i):(q=O.cachePool,q!==null?(te=Tt._currentValue,q=q.parent!==te?{parent:te,pool:te}:q):q=ax(),O={baseLanes:O.baseLanes|i,cachePool:q}),h.memoizedState=O,h.childLanes=qf(t,C,i),r.memoizedState=Uf,u):(Zr(r),i=t.child,t=i.sibling,i=xr(i,{mode:"visible",children:u.children}),i.return=r,i.sibling=null,t!==null&&(C=r.deletions,C===null?(r.deletions=[t],r.flags|=16):C.push(t)),r.child=i,r.memoizedState=null,i)}function Ff(t,r){return r=hc({mode:"visible",children:r},t.mode),r.return=t,t.child=r}function hc(t,r){return t=cn(22,t,null,r),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Yf(t,r,i){return $o(r,t.child,null,i),t=Ff(r,r.pendingProps.children),t.flags|=2,r.memoizedState=null,t}function w0(t,r,i){t.lanes|=r;var u=t.alternate;u!==null&&(u.lanes|=r),uf(t.return,r,i)}function Gf(t,r,i,u,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:r,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:h}:(x.isBackwards=r,x.rendering=null,x.renderingStartTime=0,x.last=u,x.tail=i,x.tailMode=h)}function N0(t,r,i){var u=r.pendingProps,h=u.revealOrder,x=u.tail;if($t(t,r,u.children,i),u=Rt.current,(u&2)!==0)u=u&1|2,r.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=r.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&w0(t,i,r);else if(t.tag===19)w0(t,i,r);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===r)break e;for(;t.sibling===null;){if(t.return===null||t.return===r)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(W(Rt,u),h){case"forwards":for(i=r.child,h=null;i!==null;)t=i.alternate,t!==null&&uc(t)===null&&(h=i),i=i.sibling;i=h,i===null?(h=r.child,r.child=null):(h=i.sibling,i.sibling=null),Gf(r,!1,h,i,x);break;case"backwards":for(i=null,h=r.child,r.child=null;h!==null;){if(t=h.alternate,t!==null&&uc(t)===null){r.child=h;break}t=h.sibling,h.sibling=i,i=h,h=t}Gf(r,!0,i,null,x);break;case"together":Gf(r,!1,null,null,void 0);break;default:r.memoizedState=null}return r.child}function jr(t,r,i){if(t!==null&&(r.dependencies=t.dependencies),ts|=r.lanes,(i&r.childLanes)===0)if(t!==null){if(ei(t,r,i,!1),(i&r.childLanes)===0)return null}else return null;if(t!==null&&r.child!==t.child)throw Error(o(153));if(r.child!==null){for(t=r.child,i=xr(t,t.pendingProps),r.child=i,i.return=r;t.sibling!==null;)t=t.sibling,i=i.sibling=xr(t,t.pendingProps),i.return=r;i.sibling=null}return r.child}function Xf(t,r){return(t.lanes&r)!==0?!0:(t=t.dependencies,!!(t!==null&&Xl(t)))}function p_(t,r,i){switch(r.tag){case 3:ie(r,r.stateNode.containerInfo),qr(r,Tt,t.memoizedState.cache),Qa();break;case 27:case 5:we(r);break;case 4:ie(r,r.stateNode.containerInfo);break;case 10:qr(r,r.type,r.memoizedProps.value);break;case 13:var u=r.memoizedState;if(u!==null)return u.dehydrated!==null?(Zr(r),r.flags|=128,null):(i&r.child.childLanes)!==0?b0(t,r,i):(Zr(r),t=jr(t,r,i),t!==null?t.sibling:null);Zr(r);break;case 19:var h=(t.flags&128)!==0;if(u=(i&r.childLanes)!==0,u||(ei(t,r,i,!1),u=(i&r.childLanes)!==0),h){if(u)return N0(t,r,i);r.flags|=128}if(h=r.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),W(Rt,Rt.current),u)break;return null;case 22:case 23:return r.lanes=0,p0(t,r,i);case 24:qr(r,Tt,t.memoizedState.cache)}return jr(t,r,i)}function S0(t,r,i){if(t!==null)if(t.memoizedProps!==r.pendingProps)It=!0;else{if(!Xf(t,i)&&(r.flags&128)===0)return It=!1,p_(t,r,i);It=(t.flags&131072)!==0}else It=!1,st&&(r.flags&1048576)!==0&&Jg(r,Gl,r.index);switch(r.lanes=0,r.tag){case 16:e:{t=r.pendingProps;var u=r.elementType,h=u._init;if(u=h(u._payload),r.type=u,typeof u=="function")nf(u)?(t=Fs(u,t),r.tag=1,r=y0(null,r,u,t,i)):(r.tag=0,r=Pf(null,r,u,t,i));else{if(u!=null){if(h=u.$$typeof,h===k){r.tag=11,r=f0(null,r,u,t,i);break e}else if(h===z){r.tag=14,r=m0(null,r,u,t,i);break e}}throw r=L(u)||u,Error(o(306,r,""))}}return r;case 0:return Pf(t,r,r.type,r.pendingProps,i);case 1:return u=r.type,h=Fs(u,r.pendingProps),y0(t,r,u,h,i);case 3:e:{if(ie(r,r.stateNode.containerInfo),t===null)throw Error(o(387));u=r.pendingProps;var x=r.memoizedState;h=x.element,xf(t,r),ii(r,u,null,i);var C=r.memoizedState;if(u=C.cache,qr(r,Tt,u),u!==x.cache&&df(r,[Tt],i,!0),ai(),u=C.element,x.isDehydrated)if(x={element:u,isDehydrated:!1,cache:C.cache},r.updateQueue.baseState=x,r.memoizedState=x,r.flags&256){r=v0(t,r,u,i);break e}else if(u!==h){h=Nn(Error(o(424)),r),Ja(h),r=v0(t,r,u,i);break e}else{switch(t=r.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(wt=zn(t.firstChild),Wt=r,st=!0,$s=null,Gn=!0,i=t0(r,null,u,i),r.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(Qa(),u===h){r=jr(t,r,i);break e}$t(t,r,u,i)}r=r.child}return r;case 26:return mc(t,r),t===null?(i=Cy(r.type,null,r.pendingProps,null))?r.memoizedState=i:st||(i=r.type,t=r.pendingProps,u=kc(fe.current).createElement(i),u[zt]=r,u[Ft]=t,Pt(u,i,t),Ct(u),r.stateNode=u):r.memoizedState=Cy(r.type,t.memoizedProps,r.pendingProps,t.memoizedState),null;case 27:return we(r),t===null&&st&&(u=r.stateNode=jy(r.type,r.pendingProps,fe.current),Wt=r,Gn=!0,h=wt,os(r.type)?(km=h,wt=zn(u.firstChild)):wt=h),$t(t,r,r.pendingProps.children,i),mc(t,r),t===null&&(r.flags|=4194304),r.child;case 5:return t===null&&st&&((h=u=wt)&&(u=V_(u,r.type,r.pendingProps,Gn),u!==null?(r.stateNode=u,Wt=r,wt=zn(u.firstChild),Gn=!1,h=!0):h=!1),h||Bs(r)),we(r),h=r.type,x=r.pendingProps,C=t!==null?t.memoizedProps:null,u=x.children,jm(h,x)?u=null:C!==null&&jm(h,C)&&(r.flags|=32),r.memoizedState!==null&&(h=Sf(t,r,i_,null,null,i),Ai._currentValue=h),mc(t,r),$t(t,r,u,i),r.child;case 6:return t===null&&st&&((t=i=wt)&&(i=q_(i,r.pendingProps,Gn),i!==null?(r.stateNode=i,Wt=r,wt=null,t=!0):t=!1),t||Bs(r)),null;case 13:return b0(t,r,i);case 4:return ie(r,r.stateNode.containerInfo),u=r.pendingProps,t===null?r.child=$o(r,null,u,i):$t(t,r,u,i),r.child;case 11:return f0(t,r,r.type,r.pendingProps,i);case 7:return $t(t,r,r.pendingProps,i),r.child;case 8:return $t(t,r,r.pendingProps.children,i),r.child;case 12:return $t(t,r,r.pendingProps.children,i),r.child;case 10:return u=r.pendingProps,qr(r,r.type,u.value),$t(t,r,u.children,i),r.child;case 9:return h=r.type._context,u=r.pendingProps.children,Us(r),h=Yt(h),u=u(h),r.flags|=1,$t(t,r,u,i),r.child;case 14:return m0(t,r,r.type,r.pendingProps,i);case 15:return h0(t,r,r.type,r.pendingProps,i);case 19:return N0(t,r,i);case 31:return u=r.pendingProps,i=r.mode,u={mode:u.mode,children:u.children},t===null?(i=hc(u,i),i.ref=r.ref,r.child=i,i.return=r,r=i):(i=xr(t.child,u),i.ref=r.ref,r.child=i,i.return=r,r=i),r;case 22:return p0(t,r,i);case 24:return Us(r),u=Yt(Tt),t===null?(h=hf(),h===null&&(h=ht,x=ff(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=i),h=x),r.memoizedState={parent:u,cache:h},gf(r),qr(r,Tt,h)):((t.lanes&i)!==0&&(xf(t,r),ii(r,null,null,i),ai()),h=t.memoizedState,x=r.memoizedState,h.parent!==u?(h={parent:u,cache:u},r.memoizedState=h,r.lanes===0&&(r.memoizedState=r.updateQueue.baseState=h),qr(r,Tt,u)):(u=x.cache,qr(r,Tt,u),u!==h.cache&&df(r,[Tt],i,!0))),$t(t,r,r.pendingProps.children,i),r.child;case 29:throw r.pendingProps}throw Error(o(156,r.tag))}function _r(t){t.flags|=4}function j0(t,r){if(r.type!=="stylesheet"||(r.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Ry(r)){if(r=En.current,r!==null&&((Qe&4194048)===Qe?Xn!==null:(Qe&62914560)!==Qe&&(Qe&536870912)===0||r!==Xn))throw si=pf,ix;t.flags|=8192}}function pc(t,r){r!==null&&(t.flags|=4),t.flags&16384&&(r=t.tag!==22?Fn():536870912,t.lanes|=r,Vo|=r)}function hi(t,r){if(!st)switch(t.tailMode){case"hidden":r=t.tail;for(var i=null;r!==null;)r.alternate!==null&&(i=r),r=r.sibling;i===null?t.tail=null:i.sibling=null;break;case"collapsed":i=t.tail;for(var u=null;i!==null;)i.alternate!==null&&(u=i),i=i.sibling;u===null?r||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function vt(t){var r=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(r)for(var h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags&65011712,u|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags,u|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=u,t.childLanes=i,r}function g_(t,r,i){var u=r.pendingProps;switch(af(r),r.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return vt(r),null;case 1:return vt(r),null;case 3:return i=r.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),r.memoizedState.cache!==u&&(r.flags|=2048),wr(Tt),ve(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(Ka(r)?_r(r):t===null||t.memoizedState.isDehydrated&&(r.flags&256)===0||(r.flags|=1024,nx())),vt(r),null;case 26:return i=r.memoizedState,t===null?(_r(r),i!==null?(vt(r),j0(r,i)):(vt(r),r.flags&=-16777217)):i?i!==t.memoizedState?(_r(r),vt(r),j0(r,i)):(vt(r),r.flags&=-16777217):(t.memoizedProps!==u&&_r(r),vt(r),r.flags&=-16777217),null;case 27:je(r),i=fe.current;var h=r.type;if(t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}t=se.current,Ka(r)?ex(r):(t=jy(h,u,i),r.stateNode=t,_r(r))}return vt(r),null;case 5:if(je(r),i=r.type,t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}if(t=se.current,Ka(r))ex(r);else{switch(h=kc(fe.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=h.createElement("div"),t.innerHTML="