diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 523a763b62..3c6c620614 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -171,7 +171,7 @@ jobs: -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread - --retries 2 --retry-delay 5 + --retries 2 --retry-delay 30 - name: Stop local MCP server if: always() shell: bash diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 4fc47af595..454b297bed 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -287,7 +287,7 @@ jobs: -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread - --retries 2 --retry-delay 5 + --retries 2 --retry-delay 30 --junitxml=pytest.xml working-directory: ./python - name: Stop local MCP server diff --git a/dotnet/Directory.Build.targets b/dotnet/Directory.Build.targets index 5e62f1cef7..258606c295 100644 --- a/dotnet/Directory.Build.targets +++ b/dotnet/Directory.Build.targets @@ -4,8 +4,9 @@ - - + + + diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0270f0e38b..4e32c2198f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -11,8 +11,8 @@ - - + + @@ -32,19 +32,19 @@ - + - + - - + + @@ -63,37 +63,28 @@ - - - - - - + + + - + - + - + - - - - - - @@ -107,11 +98,10 @@ - - + @@ -126,7 +116,6 @@ - diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index d2c17a5541..a12ca1c5ad 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using AGUIServer; using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; using OpenAI.Chat; @@ -13,11 +14,11 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default)); builder.Services.AddAGUI(); -WebApplication app = builder.Build(); - string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); +const string AgentName = "AGUIAssistant"; + // Create the AI agent with tools // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -27,7 +28,7 @@ var agent = new AzureOpenAIClient( new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( - name: "AGUIAssistant", + name: AgentName, tools: [ AIFunctionFactory.Create( () => DateTimeOffset.UtcNow, @@ -48,7 +49,15 @@ var agent = new AzureOpenAIClient( AGUIServerSerializerContext.Default.Options) ]); +// Register the agent with the host and configure it to use an in-memory session store +// so that conversation state is maintained across requests. In production, you may want to use a persistent session store. +builder + .AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(); + +WebApplication app = builder.Build(); + // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUI(AgentName, "/"); await app.RunAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj b/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj index 0cd6eeb37d..ec2e0df971 100644 --- a/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj @@ -1,7 +1,7 @@  - true + false enable true 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 e20d1ab448..948ecdca42 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -21,6 +24,42 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; /// public static class AGUIEndpointRouteBuilderExtensions { + /// + /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via . + /// + /// The endpoint route builder. + /// The hosted agent builder that identifies the agent registration. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + IHostedAgentBuilder agentBuilder, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapAGUI(agentBuilder.Name, pattern); + } + + /// + /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection. + /// + /// The endpoint route builder. + /// The name of the keyed agent registration to resolve from dependency injection. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapAGUI(pattern, agent); + } + /// /// Maps an AG-UI agent endpoint. /// @@ -28,11 +67,24 @@ public static class AGUIEndpointRouteBuilderExtensions /// The URL pattern for the endpoint. /// The agent instance. /// An for the mapped endpoint. + /// + /// + /// If an is registered in dependency injection keyed by the agent's name, + /// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the + /// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted). + /// + /// public static IEndpointConventionBuilder MapAGUI( this IEndpointRouteBuilder endpoints, [StringSyntax("route")] string pattern, AIAgent aiAgent) { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(aiAgent); + + var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name); + var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore()); + return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => { if (input is null) @@ -63,21 +115,43 @@ public static class AGUIEndpointRouteBuilderExtensions } }; + var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId; + var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + // Run the agent and convert to AG-UI events - var events = aiAgent.RunStreamingAsync( + var events = hostAgent.RunStreamingAsync( messages, + session: session, options: runOptions, cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken) .AsAGUIEventStreamAsync( - input.ThreadId, + threadId, input.RunId, jsonSerializerOptions, cancellationToken); + // Wrap the event stream to save the session after streaming completes + var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); + var sseLogger = context.RequestServices.GetRequiredService>(); - return new AGUIServerSentEventsResult(events, sseLogger); + return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); }); } + + private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( + IAsyncEnumerable events, + AIHostAgent hostAgent, + string threadId, + AgentSession session, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (BaseEvent evt in events.ConfigureAwait(false)) + { + yield return evt; + } + + await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index d6169ad805..1565977149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 4e9f201053..4c93414c63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -8,6 +8,10 @@ using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +using ExecutorFactoryFunc = System.Func, + string, + System.Threading.Tasks.ValueTask>; + namespace Microsoft.Agents.AI.Workflows; internal static class DiagnosticConstants @@ -233,6 +237,57 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } + private Dictionary CreateExecutorBindings(WorkflowBuilder builder) + { + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); + + // There are two types of ids being used in this method, and it is critical that we are clear about + // which one we are using, and where. + // AgentId...: comes from AIAgent.Id, is often an unreadable machine identifier (e.g. a Guid), and is used to address + // the handoffs + // ExecutorId: uses AIAgent.GetDescriptiveId() to use a friendlier name in telemetry, and is used for ExecutorBinding, + // which are subsequently used in building the workflow + + // The outgoing dictionary maps from AgentId => ExecutorBinding + return this._allAgents.ToDictionary(keySelector: a => a.Id, elementSelector: CreateFactoryBinding); + + ExecutorBinding CreateFactoryBinding(AIAgent agent) + { + if (!this._targets.TryGetValue(agent, out HashSet? handoffs)) + { + handoffs = new(); + } + + // Use the ExecutorId as the placeholder id for a (possibly) future-bound factory + builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) => + { + foreach (HandoffTarget handoff in handoffs) + { + sb.AddCase(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching + HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level + } + + sb.WithDefault(HandoffEndExecutor.ExecutorId); + }); + + ExecutorFactoryFunc factory = + (config, sessionId) => new( + new HandoffAgentExecutor(agent, + handoffs, + options)); + + // Make sure to use ExecutorId when binding the executor, not AgentId + ExecutorBinding binding = factory.BindExecutor(HandoffAgentExecutor.IdFor(agent)); + + builder.BindExecutor(binding); + + return binding; + } + } + /// /// Builds a composed of agents that operate via handoffs, with the next /// agent to process messages selected by the current agent. @@ -240,17 +295,12 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(this._returnToPrevious); - HandoffsEndExecutor end = new(this._returnToPrevious); + HandoffStartExecutor start = new(this._returnToPrevious); + HandoffEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, - this._emitAgentResponseEvents, - this._emitAgentResponseUpdateEvents, - this._toolCallFilteringBehavior); - - // Create an AgentExecutor for each agent. - Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); + // Create an factory-based ExecutorBinding for each agent. + Dictionary executors = this.CreateExecutorBindings(builder); // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). if (this._returnToPrevious) @@ -263,7 +313,7 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl if (agent.Id != initialAgentId) { string agentId = agent.Id; - sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + sb.AddCase(state => state?.PreviousAgentId == agentId, executors[agentId]); } } @@ -275,13 +325,6 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.AddEdge(start, executors[this._initialAgent.Id]); } - // Initialize each executor with its handoff targets to the other executors. - foreach (var agent in this._allAgents) - { - executors[agent.Id].Initialize(builder, end, executors, - this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); - } - // Build the workflow. return builder.WithOutputFrom(end).Build(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index f0bb8cac26..d6c7d301e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -419,6 +419,12 @@ internal sealed class InProcessRunnerContext : IRunnerContext .Select(id => this.EnsureExecutorAsync(id, tracer: null).AsTask()) .ToArray(); + // Discard queued external deliveries from the superseded timeline so a runtime + // restore cannot apply stale responses after importing the checkpoint state. + while (this._queuedExternalDeliveries.TryDequeue(out _)) + { + } + this._nextStep = new StepContext(); this._nextStep.ImportMessages(importedState.QueuedMessages); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 3f3d83fbee..b7d2911537 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -19,6 +19,9 @@ internal static class TurnExtensions public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) => turnTokenSetting ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(this HandoffState handoffState, bool? agentSetting) + => handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting); } internal sealed class AIAgentHostExecutor : ChatProtocolExecutor @@ -81,7 +84,11 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.User, [response])); + pendingMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -104,7 +111,12 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])); + pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -186,16 +198,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), cancellationToken); - private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) { -#pragma warning disable MEAI001 - Dictionary userInputRequests = new(); - Dictionary functionCalls = new(); AgentResponse response; + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); - if (emitEvents) + if (emitUpdateEvents) { -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // Run the agent in streaming mode only when agent run update events are to be emitted. IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( messages, @@ -206,7 +215,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); - ExtractUnservicedRequests(update.Contents); + collector.ProcessAgentResponseUpdate(update); updates.Add(update); } @@ -220,7 +229,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor cancellationToken: cancellationToken) .ConfigureAwait(false); - ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents)); + collector.ProcessAgentResponse(response); } if (this._options.EmitAgentResponseEvents) @@ -228,45 +237,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } - if (userInputRequests.Count > 0 || functionCalls.Count > 0) - { - Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask; - Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask; - - await Task.WhenAll(userInputTask, functionCallTask) - .ConfigureAwait(false); - } + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); return response; - - void ExtractUnservicedRequests(IEnumerable contents) - { - foreach (AIContent content in contents) - { - if (content is ToolApprovalRequestContent userInputRequest) - { - // It is an error to simultaneously have multiple outstanding user input requests with the same ID. - userInputRequests.Add(userInputRequest.RequestId, userInputRequest); - } - else if (content is ToolApprovalResponseContent userInputResponse) - { - // If the set of messages somehow already has a corresponding user input response, remove it. - _ = userInputRequests.Remove(userInputResponse.RequestId); - } - else if (content is FunctionCallContent functionCall) - { - // For function calls, we emit an event to notify the workflow. - // - // possibility 1: this will be handled inline by the agent abstraction - // possibility 2: this will not be handled inline by the agent abstraction - functionCalls.Add(functionCall.CallId, functionCall); - } - else if (content is FunctionResultContent functionResult) - { - _ = functionCalls.Remove(functionResult.CallId); - } - } - } -#pragma warning restore MEAI001 } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs new file mode 100644 index 0000000000..7e4f8c8c9d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandler? userInputHandler, + AIContentExternalHandler? functionCallHandler) +{ + private readonly Dictionary _userInputRequests = []; + private readonly Dictionary _functionCalls = []; + + public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0 + ? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken) + : Task.CompletedTask; + + Task functionCallTask = functionCallHandler != null && this._functionCalls.Count > 0 + ? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken) + : Task.CompletedTask; + + return Task.WhenAll(userInputTask, functionCallTask); + } + + public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func? functionCallFilter = null) + => this.ProcessAIContents(update.Contents, functionCallFilter); + + public void ProcessAgentResponse(AgentResponse response) + => this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents)); + + public void ProcessAIContents(IEnumerable contents, Func? functionCallFilter = null) + { + foreach (AIContent content in contents) + { + if (content is ToolApprovalRequestContent userInputRequest) + { + if (this._userInputRequests.ContainsKey(userInputRequest.RequestId)) + { + throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}"); + } + + // It is an error to simultaneously have multiple outstanding user input requests with the same ID. + this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest); + } + else if (content is ToolApprovalResponseContent userInputResponse) + { + // If the set of messages somehow already has a corresponding user input response, remove it. + _ = this._userInputRequests.Remove(userInputResponse.RequestId); + } + else if (content is FunctionCallContent functionCall) + { + // For function calls, we emit an event to notify the workflow. + // + // possibility 1: this will be handled inline by the agent abstraction + // possibility 2: this will not be handled inline by the agent abstraction + if (functionCallFilter == null || functionCallFilter(functionCall)) + { + if (this._functionCalls.ContainsKey(functionCall.CallId)) + { + throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}"); + } + + this._functionCalls.Add(functionCall.CallId, functionCall); + } + } + else if (content is FunctionResultContent functionResult) + { + _ = this._functionCalls.Remove(functionResult.CallId); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index e885b894fd..eac2eb5687 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; @@ -166,128 +165,331 @@ internal sealed class HandoffMessagesFilter } } +internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) +{ + public AgentResponse Response => agentResponse; + + public string? HandoffTargetId => handoffTargetId; + + [MemberNotNullWhen(true, nameof(HandoffTargetId))] + public bool IsHandoffRequested => this.HandoffTargetId != null; +} + +internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List FilteredIncomingMessages, List TurnMessages) +{ + public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId) + { + if (this.CurrentTurnState == null) + { + throw new InvalidOperationException("Cannot create a handoff request: Out of turn."); + } + + IEnumerable allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages]; + + return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId); + } +} + /// Executor used to represent an agent in a handoffs workflow, responding to events. [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -internal sealed class HandoffAgentExecutor( - AIAgent agent, - HandoffAgentExecutorOptions options) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffAgentExecutor : + StatefulExecutor { private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; - private readonly AIAgent _agent = agent; + public static string IdFor(AIAgent agent) => agent.GetDescriptiveId(); + + private readonly AIAgent _agent; + private readonly ChatClientAgentRunOptions? _agentOptions; + + private readonly HandoffAgentExecutorOptions _options; + private readonly HashSet _handoffFunctionNames = []; private readonly Dictionary _handoffFunctionToAgentId = []; - private ChatClientAgentRunOptions? _agentOptions; - public void Initialize( - WorkflowBuilder builder, - Executor end, - Dictionary executors, - HashSet handoffs) => - builder.AddSwitch(this, sb => - { - if (handoffs.Count != 0) - { - Debug.Assert(this._agentOptions is null); - this._agentOptions = new() - { - ChatOptions = new() - { - AllowMultipleToolCalls = false, - Instructions = options.HandoffInstructions, - Tools = [], - }, - }; + private static HandoffAgentHostState InitialStateFactory() => new(null, [], []); - int index = 0; - foreach (HandoffTarget handoff in handoffs) - { - index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); - - this._handoffFunctionNames.Add(handoffFunc.Name); - this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; - - this._agentOptions.ChatOptions.Tools.Add(handoffFunc); - - sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); - } - } - - sb.WithDefault(end); - }); - - public override async ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + public HandoffAgentExecutor(AIAgent agent, HashSet handoffs, HandoffAgentExecutorOptions options) + : base(IdFor(agent), InitialStateFactory) { - string? requestedHandoff = null; - List updates = []; - List allMessages = message.Messages; + this._agent = agent; + this._options = options; - List? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId); + } - // If a handoff was invoked by a previous agent, filter out the handoff function - // call and tool result messages before sending to the underlying agent. These - // are internal workflow mechanics that confuse the target model into ignoring the - // original user question. - HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior); - IEnumerable messagesForAgent = message.InvokedHandoff is not null - ? handoffMessagesFilter.FilterMessages(allMessages) - : allMessages; + private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet handoffs, HashSet functionNames, Dictionary functionToAgentId) + { + ChatClientAgentRunOptions? result = null; - await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent, - options: this._agentOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false)) + if (handoffs.Count != 0) { - await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - - foreach (var fcc in update.Contents.OfType() - .Where(fcc => this._handoffFunctionNames.Contains(fcc.Name))) + result = new() { - requestedHandoff = fcc.Name; - await AddUpdateAsync( - new AgentResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.Name ?? this._agent.Id, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }, - cancellationToken - ) - .ConfigureAwait(false); + ChatOptions = new() + { + AllowMultipleToolCalls = false, + Instructions = handoffInstructions, + Tools = [], + }, + }; + + int index = 0; + foreach (HandoffTarget handoff in handoffs) + { + index++; + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + + functionNames.Add(handoffFunc.Name); + functionToAgentId[handoffFunc.Name] = handoff.Target.Id; + + result.ChatOptions.Tools.Add(handoffFunc); } } - AgentResponse agentResponse = updates.ToAgentResponse(); + return result; + } - if (options.EmitAgentResponseEvents) + private AIContentExternalHandler? _userInputHandler; + private AIContentExternalHandler? _functionCallHandler; + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + { + return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder)) + .SendsMessage(); + } + + private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder) + { + this._userInputHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_UserInput", + intercepted: false, + handler: this.HandleUserInputResponseAsync); + + this._functionCallHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_FunctionCall", + intercepted: false, // TODO: Use this instead of manual function handling for handoff? + handler: this.HandleFunctionResultAsync); + + return protocolBuilder; + } + + private ValueTask HandleUserInputResponseAsync( + ToolApprovalResponseContent response, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId)) { - await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'."); } - allMessages.AddRange(agentResponse.Messages); + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private ValueTask HandleFunctionResultAsync( + FunctionResultContent result, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId)) + { + throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'."); + } + + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add( + new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private async ValueTask ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + List? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + + bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents); + AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken) + .ConfigureAwait(false); + + if (this.HasOutstandingRequests && result.IsHandoffRequested) + { + throw new InvalidOperationException("Cannot request a handoff while holding pending requests."); + } roleChanges.ResetUserToAssistantForChangedRoles(); - string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) - ? targetAgentId - : this._agent.Id; - - return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); - - async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + // We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only + // happens if we have no outstanding requests. + if (!this.HasOutstandingRequests) { - updates.Add(update); - if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) + HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id); + + await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); + + // reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which + // can be a bit confusing.) + return null; + } + + state.TurnMessages.AddRange(result.Response.Messages); + return state; + } + + public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken); + + ValueTask InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + // Check that we are not getting this message while in the middle of a turn + if (state.CurrentTurnState != null) { - await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration."); } + + // If a handoff was invoked by a previous agent, filter out the handoff function + // call and tool result messages before sending to the underlying agent. These + // are internal workflow mechanics that confuse the target model into ignoring the + // original user question. + HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior); + IEnumerable messagesForAgent = message.RequestedHandoffTargetAgentId is not null + ? handoffMessagesFilter.FilterMessages(message.Messages) + : message.Messages; + + // This works because the runtime guarantees that a given executor instance will process messages serially, + // though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order) + state = new(message, messagesForAgent.ToList(), []); + + return this.ContinueTurnAsync(state, context, cancellationToken); } } - public ValueTask ResetAsync() => default; + private const string UserInputRequestStateKey = nameof(_userInputHandler); + private const string FunctionCallRequestStateKey = nameof(_functionCallHandler); + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); + await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false); + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true) + || (this._functionCallHandler?.HasPendingRequests == true); + + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) + { + AgentResponse response; + + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); + + IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( + messages, + options: this._agentOptions, + cancellationToken: cancellationToken); + + string? requestedHandoff = null; + List updates = []; + List candidateRequests = []; + await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) + { + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); + + collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); + + bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + { + bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + if (isHandoffRequest) + { + candidateRequests.Add(candidateHandoffRequest); + } + + return !isHandoffRequest; + } + } + + if (candidateRequests.Count > 1) + { + string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})"; + await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false); + } + + if (candidateRequests.Count > 0) + { + FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1]; + requestedHandoff = handoffRequest.Name; + + await AddUpdateAsync( + new AgentResponseUpdate + { + AgentId = this._agent.Id, + AuthorName = this._agent.Name ?? this._agent.Id, + Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }, + cancellationToken + ) + .ConfigureAwait(false); + } + + response = updates.ToAgentResponse(); + + if (this._options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); + } + + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); + + return new(response, LookupHandoffTarget(requestedHandoff)); + + ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + { + updates.Add(update); + + return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default; + } + + string? LookupHandoffTarget(string? requestedHandoff) + => requestedHandoff != null + ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null + : null; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs similarity index 86% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs index 4a43c00a72..0ba8fc3501 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; @@ -21,9 +21,9 @@ internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(Exec { if (returnToPrevious) { - await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, - handoff.CurrentAgentId, - HandoffConstants.CurrentAgentTrackerScope, + await context.QueueStateUpdateAsync(HandoffConstants.PreviousAgentTrackerKey, + handoff.PreviousAgentId, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs similarity index 71% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs index 87c3b4566b..063f73bb6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs @@ -9,12 +9,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal static class HandoffConstants { - internal const string CurrentAgentTrackerKey = "LastAgentId"; - internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; + internal const string PreviousAgentTrackerKey = "LastAgentId"; + internal const string PreviousAgentTrackerScope = "HandoffOrchestration"; } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -32,15 +32,15 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco if (returnToPrevious) { return context.InvokeWithStateAsync( - async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) => { - HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId); await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); - return currentAgentId; + return previousAgentId; }, - HandoffConstants.CurrentAgentTrackerKey, - HandoffConstants.CurrentAgentTrackerScope, + HandoffConstants.PreviousAgentTrackerKey, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index 56e2fef9df..644bc7df0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -7,6 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, - string? InvokedHandoff, + string? RequestedHandoffTargetAgentId, List Messages, - string? CurrentAgentId = null); + string? PreviousAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 3ed23cc019..d1d239506f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -113,6 +113,12 @@ public abstract class StatefulExecutor : Executor { if (!skipCache && !context.ConcurrentRunsEnabled) { + if (this._stateCache is null) + { + this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken) + .ConfigureAwait(false); + } + TState newState = await invocation(this._stateCache ?? this._initialStateFactory(), context, cancellationToken).ConfigureAwait(false) @@ -168,9 +174,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); + Func handlerDelegate = this.HandleAsync; - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []) + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) .YieldsOutputTypes(outputTypes ?? []); } @@ -203,19 +212,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); - - if (this.Options.AutoSendMessageHandlerResultObject) - { - protocolBuilder.SendsMessage(); - } - - if (this.Options.AutoYieldOutputHandlerResultObject) - { - protocolBuilder.YieldsOutput(); - } - - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []); + Func> handlerDelegate = this.HandleAsync; + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) + .YieldsOutputTypes(outputTypes ?? []); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs index 6485eaa85b..cccac81eba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs @@ -483,6 +483,14 @@ public sealed class AnthropicBetaServiceExtensionsTests public IBetaMessageService Messages => new Mock().Object; + public global::Anthropic.Services.Beta.IAgentService Agents => throw new NotImplementedException(); + + public global::Anthropic.Services.Beta.IEnvironmentService Environments => throw new NotImplementedException(); + + public global::Anthropic.Services.Beta.ISessionService Sessions => throw new NotImplementedException(); + + public global::Anthropic.Services.Beta.IVaultService Vaults => throw new NotImplementedException(); + public IBetaService WithOptions(Func modifier) { throw new NotImplementedException(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index 490f816cd4..e0b072a44b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -21,6 +21,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs new file mode 100644 index 0000000000..785a3b2e00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +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 SessionPersistenceTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() + { + // Arrange - use hosting DI pattern with InMemorySessionStore. + // FakeSessionAgent tracks turn count in session StateBag so we can verify + // that state survives the serialization round-trip through the session store. + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + // Act - First turn + ChatMessage firstUserMessage = new(ChatRole.User, "First message"); + List firstTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + firstTurnUpdates.Add(update); + } + + // Act - Second turn (same thread ID to test session persistence) + ChatMessage secondUserMessage = new(ChatRole.User, "Second message"); + List secondTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + secondTurnUpdates.Add(update); + } + + // Assert - Verify turn count proves session state was persisted. + // If session persistence were broken, both turns would return "Turn 1" + // because a fresh session (with turn count 0) would be created each time. + AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse(); + firstResponse.Messages.Should().HaveCount(1); + firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + firstResponse.Messages[0].Text.Should().Contain("Turn 1:"); + + AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse(); + secondResponse.Messages.Should().HaveCount(1); + secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + secondResponse.Messages[0].Text.Should().Contain("Turn 2:"); + } + + [Fact] + public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync() + { + // Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + ChatMessage userMessage = new(ChatRole.User, "hello"); + + List updates = []; + + // Act + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + updates.Add(update); + } + + // Assert + updates.Should().NotBeEmpty(); + updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant)); + + AgentResponse response = updates.ToAgentResponse(); + response.Messages.Should().HaveCount(1); + response.Messages[0].Role.Should().Be(ChatRole.Assistant); + response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!"); + } + + private async Task SetupTestServerWithSessionStoreAsync() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddAGUI(); + + // Register agent using hosting DI pattern with InMemorySessionStore + builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) + .WithInMemorySessionStore(); + + this._app = builder.Build(); + + // Use the agentName overload of MapAGUI + this._app.MapAGUI("session-test-agent", "/agent"); + + 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 via dependency injection")] +internal sealed class FakeSessionAgent : AIAgent +{ + private readonly string _name; + + public FakeSessionAgent(string name) + { + this._name = name; + } + + protected override string? IdCore => this._name; + + public override string? Name => this._name; + + public override string? Description => "A fake agent with session support for testing"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new FakeSessionAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not FakeSessionAgentSession fakeSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent."); + } + + return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions)); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Track turn count in session state to enable persistence verification. + // If the session store works correctly, the turn count increments across requests. + int turnCount = 1; + if (session != null) + { + var counter = session.StateBag.GetValue("turnCounter"); + turnCount = (counter?.Count ?? 0) + 1; + session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount }); + } + + string messageId = Guid.NewGuid().ToString("N"); + string prefix = $"Turn {turnCount}: "; + + foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" }) + { + yield return new AgentResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new TextContent(chunk)] + }; + + await Task.Yield(); + } + } + + internal sealed class TurnCounter + { + public int Count { get; set; } + } + + private sealed class FakeSessionAgentSession : AgentSession + { + public FakeSessionAgentSession() + { + } + + [JsonConstructor] + public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} 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 84a20e1938..248629b392 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 @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -31,6 +32,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -45,6 +47,155 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests Assert.NotNull(result); } + [Fact] + public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock agentBuilderMock = new(); + AIAgent agent = new NamedTestAgent(); + + agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock sessionStoreMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent")) + .Returns(sessionStoreMock.Object); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new TestAgent(); + + // No session store registered - IKeyedServiceProvider returns null by default + serviceProviderMock.As(); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act - should not throw (falls back to NoopAgentSessionStore) + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AIAgent agent = new TestAgent(); + + // Act & Assert + Assert.Throws(() => + AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent)); + } + + [Fact] + public void MapAGUI_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!)); + } + + [Fact] + public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((string)null!, "/api/agent")); + } + + [Fact] + public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent")); + } + [Fact] public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync() { @@ -556,4 +707,44 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); } } + + private sealed class NamedTestAgent : AIAgent + { + protected override string? IdCore => "test-agent"; + + public override string? Name => "test-agent"; + + public override string? Description => "Named test agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TestAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not TestAgentSession testSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); + } + + return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 7f06145a8e..c857811b08 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; @@ -147,7 +148,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +226,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +259,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +297,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +407,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +605,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -634,6 +635,232 @@ public class AgentWorkflowBuilderTests Assert.Contains("thirdAgent", result[5].AuthorName); } + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionCallId = "call2first"; + + AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction)); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + ToolApprovalRequestContent approvalRequest = + request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId); + + ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied")); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(10, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation (and user denial) + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.User, result[4].Role); + Assert.Equal("", result[4].Text); + + // Rejected tool call + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("", result[7].Text); + Assert.Contains("secondAgent", result[7].AuthorName); + + Assert.Equal(ChatRole.Tool, result[8].Role); + Assert.Contains("secondAgent", result[8].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[9].Role); + Assert.Equal("Hello from agent3", result[9].Text); + Assert.Contains("thirdAgent", result[9].AuthorName); + + static bool SomeOtherFunction() => true; + } + + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionName = "SomeOtherFunction"; + const string SomeOtherFunctionCallId = "call2first"; + + JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema; + AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + FunctionCallContent functionCall = request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + functionCall.CallId.Should().Be(SomeOtherFunctionCallId); + functionCall.Name.Should().Be(SomeOtherFunctionName); + + ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true)); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(8, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("Hello from agent3", result[7].Text); + Assert.Contains("thirdAgent", result[7].AuthorName); + } + [Theory] [InlineData(1)] [InlineData(2)] @@ -651,7 +878,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -832,7 +1059,7 @@ public class AgentWorkflowBuilderTests Assert.Equal(1, specialistCallCount); // specialist NOT called } - private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); private static Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) @@ -843,6 +1070,15 @@ public class AgentWorkflowBuilderTests return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); } + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint); + } + private static async Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) { @@ -853,15 +1089,39 @@ public class AgentWorkflowBuilderTests await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + return await ProcessWorkflowRunAsync(run); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.SendResponseAsync(response); + + return await ProcessWorkflowRunAsync(run); + } + + private static async Task ProcessWorkflowRunAsync(StreamingRun run) + { StringBuilder sb = new(); WorkflowOutputEvent? output = null; CheckpointInfo? lastCheckpoint = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + + List pendingRequests = []; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) { switch (evt) { - case AgentResponseUpdateEvent executorComplete: - sb.Append(executorComplete.Data); + case AgentResponseUpdateEvent responseUpdate: + sb.Append(responseUpdate.Data); + break; + + case RequestInfoEvent requestInfo: + pendingRequests.Add(requestInfo); break; case WorkflowOutputEvent e: @@ -878,7 +1138,7 @@ public class AgentWorkflowBuilderTests } } - return new(sb.ToString(), output?.As>(), lastCheckpoint); + return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); } private static Task RunWorkflowAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs index 9d4b514af7..53ea644712 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -279,6 +279,48 @@ public class CheckpointResumeTests "the workflow should be able to continue after the runtime restore replay"); } + /// + /// Verifies that restoring a live run clears any queued external responses from the + /// superseded timeline before importing checkpoint state. + /// + [Fact] + internal async Task Checkpoint_Restore_ClearsQueuedExternalResponsesBeforeImportAsync() + { + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + await run.RestoreCheckpointAsync(checkpoint); + + List restoredEvents = await ReadToHaltAsync(run); + ExternalRequest replayedRequest = restoredEvents.OfType() + .Select(evt => evt.Request) + .Should() + .ContainSingle("the restored run should still be waiting for the checkpointed request") + .Subject; + + restoredEvents.OfType().Should().BeEmpty( + "a queued response from the superseded timeline should not be processed after restore"); + RunStatus statusAfterRestore = await run.GetStatusAsync(); + statusAfterRestore.Should().Be(RunStatus.PendingRequests, + "the restored run should remain pending until a post-restore response is sent"); + + await run.SendResponseAsync(replayedRequest.CreateResponse("Again")); + + List completionEvents = await ReadToHaltAsync(run); + completionEvents.OfType().Should().BeEmpty( + "the restored request should complete cleanly once a new response is provided"); + RunStatus finalStatus = await run.GetStatusAsync(); + finalStatus.Should().Be(RunStatus.Idle, + "the workflow should finish once the replayed request receives a fresh response"); + } + /// /// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 8bdbe23c5f..1a5b2ea4d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -29,7 +29,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: executorSetting, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act @@ -57,7 +57,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: false, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act diff --git a/python/.env.example b/python/.env.example index e8644ea003..bff78961aa 100644 --- a/python/.env.example +++ b/python/.env.example @@ -38,6 +38,9 @@ COPILOTSTUDIOAGENT__AGENTAPPID="" # Anthropic ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="" +# Google Gemini +GEMINI_API_KEY="" +GEMINI_MODEL="" # Ollama OLLAMA_ENDPOINT="" OLLAMA_MODEL="" diff --git a/python/.github/skills/python-feature-lifecycle/SKILL.md b/python/.github/skills/python-feature-lifecycle/SKILL.md index d9b654a9da..80e8af17c2 100644 --- a/python/.github/skills/python-feature-lifecycle/SKILL.md +++ b/python/.github/skills/python-feature-lifecycle/SKILL.md @@ -1,5 +1,3 @@ -# Copyright (c) Microsoft. All rights reserved. - --- name: python-feature-lifecycle description: > diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 99947710c9..4adafae53c 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200)) + +## [devui-1.0.0b260414] - 2026-04-14 + +### Fixed +- **agent-framework-devui**: Fix streaming memory growth in DevUI frontend ([#5221](https://github.com/microsoft/agent-framework/pull/5221)) + ## [1.0.1] - 2026-04-09 ### Added diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 7a726812ff..e6b5f403ce 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -31,6 +31,7 @@ Status is grouped into these buckets: | `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | | `agent-framework-foundry` | `python/packages/foundry` | `released` | | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | +| `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 9f0ca69163..a07be3cf2f 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -374,6 +374,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): contents=contents, role="assistant" if item.role == A2ARole.agent else "user", response_id=str(getattr(item, "message_id", uuid.uuid4())), + additional_properties={"a2a_metadata": item.metadata} if item.metadata else None, raw_representation=item, ) all_updates.append(update) @@ -452,13 +453,24 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): role=message.role, response_id=task.id, message_id=getattr(message.raw_representation, "artifact_id", None), + additional_properties={"a2a_metadata": merged} + if (merged := {**message.additional_properties, **(task.metadata or {})}) + else None, raw_representation=task, ) for message in task_messages ] if task.artifacts is not None: return [] - return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] + return [ + AgentResponseUpdate( + contents=[], + role="assistant", + response_id=task.id, + additional_properties={"a2a_metadata": task.metadata} if task.metadata else None, + raw_representation=task, + ) + ] if background and status.state in IN_PROGRESS_TASK_STATES: token = self._build_continuation_token(task) @@ -468,6 +480,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): role="assistant", response_id=task.id, continuation_token=token, + additional_properties={"a2a_metadata": task.metadata} if task.metadata else None, raw_representation=task, ) ] @@ -488,6 +501,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): contents=contents, role="assistant" if status.message.role == A2ARole.agent else "user", response_id=task.id, + additional_properties={"a2a_metadata": task.metadata} if task.metadata else None, raw_representation=task, ) ] @@ -502,12 +516,17 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): contents = self._parse_contents_from_a2a(update_event.artifact.parts) if not contents: return [] + merged_metadata = { + **(update_event.artifact.metadata or {}), + **(update_event.metadata or {}), + } or None return [ AgentResponseUpdate( contents=contents, role="assistant", response_id=update_event.task_id, message_id=update_event.artifact.artifact_id, + additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None, raw_representation=update_event, ) ] @@ -523,11 +542,16 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): if not contents: return [] + merged_metadata = { + **(message.metadata or {}), + **(update_event.metadata or {}), + } or None return [ AgentResponseUpdate( contents=contents, role="assistant" if message.role == A2ARole.agent else "user", response_id=update_event.task_id, + additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None, raw_representation=update_event, ) ] @@ -642,9 +666,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): case _: raise ValueError(f"Unknown content type: {content.type}") - # Exclude framework-internal keys (e.g. attribution) from wire metadata - internal_keys = {"_attribution", "context_id"} - metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None + metadata = message.additional_properties.get("a2a_metadata") return A2AMessage( role=A2ARole("user"), @@ -718,6 +740,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): Message( role="assistant" if history_item.role == A2ARole.agent else "user", contents=contents, + additional_properties=history_item.metadata, raw_representation=history_item, ) ) @@ -730,5 +753,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): return Message( role="assistant", contents=contents, + additional_properties=artifact.metadata, raw_representation=artifact, ) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 442960a7ee..484d71e22c 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -530,7 +530,7 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None: message = Message( role="user", contents=[Content.from_text(text="Continue the task")], - additional_properties={"context_id": "ctx-123", "trace_id": "trace-456"}, + additional_properties={"context_id": "ctx-123", "a2a_metadata": {"trace_id": "trace-456"}}, ) result = agent._prepare_message_for_a2a(message) @@ -1385,3 +1385,210 @@ async def test_streaming_terminal_task_only_emits_unstreamed_artifacts( # endregion + +# region Metadata propagation tests + + +async def test_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """A2AMessage.metadata should appear on response.additional_properties.""" + msg = A2AMessage( + message_id="msg-meta", + role=A2ARole.agent, + parts=[Part(root=TextPart(text="hi"))], + metadata={"source": "server", "trace_id": "abc"}, + ) + mock_a2a_client.responses.append(msg) + + response = await a2a_agent.run("hello") + assert response.additional_properties["a2a_metadata"]["source"] == "server" + assert response.additional_properties["a2a_metadata"]["trace_id"] == "abc" + + +async def test_artifact_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Artifact.metadata should appear on response.additional_properties.""" + task = Task( + id="task-art-meta", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + artifacts=[ + Artifact( + artifact_id="a1", + parts=[Part(root=TextPart(text="result"))], + metadata={"artifact_key": "artifact_value"}, + ), + ], + ) + mock_a2a_client.responses.append((task, None)) + + response = await a2a_agent.run("go") + assert response.additional_properties["a2a_metadata"]["artifact_key"] == "artifact_value" + + +async def test_task_metadata_propagated_to_response(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Task.metadata should appear on response.additional_properties for terminal tasks.""" + task = Task( + id="task-meta", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + artifacts=[ + Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]), + ], + metadata={"task_key": "task_value"}, + ) + mock_a2a_client.responses.append((task, None)) + + response = await a2a_agent.run("go") + assert response.additional_properties["a2a_metadata"]["task_key"] == "task_value" + + +async def test_task_artifact_update_event_metadata_merged(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """TaskArtifactUpdateEvent and Artifact metadata should both appear on the streaming update.""" + artifact_event = TaskArtifactUpdateEvent( + task_id="task-ae", + context_id="ctx", + artifact=Artifact( + artifact_id="a1", + parts=[Part(root=TextPart(text="chunk"))], + metadata={"from_artifact": True}, + ), + metadata={"from_event": True}, + ) + working_task = Task( + id="task-ae", + context_id="ctx", + status=TaskStatus(state=TaskState.working), + ) + terminal_task = Task( + id="task-ae", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + artifacts=[ + Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="chunk"))]), + ], + ) + terminal_event = TaskStatusUpdateEvent( + task_id="task-ae", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + final=True, + ) + mock_a2a_client.responses.extend([ + (working_task, artifact_event), + (terminal_task, terminal_event), + ]) + + stream = a2a_agent.run("hello", stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + + artifact_update = updates[0] + assert artifact_update.additional_properties["a2a_metadata"]["from_artifact"] is True + assert artifact_update.additional_properties["a2a_metadata"]["from_event"] is True + + +async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """TaskStatusUpdateEvent and its message metadata should both appear on the streaming update.""" + status_event = TaskStatusUpdateEvent( + task_id="task-se", + context_id="ctx", + status=TaskStatus( + state=TaskState.working, + message=A2AMessage( + message_id="m1", + role=A2ARole.agent, + parts=[Part(root=TextPart(text="working..."))], + metadata={"msg_key": "msg_val"}, + ), + ), + final=False, + metadata={"event_key": "event_val"}, + ) + working_task = Task( + id="task-se", + context_id="ctx", + status=TaskStatus(state=TaskState.working), + ) + terminal_task = Task( + id="task-se", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + artifacts=[ + Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]), + ], + ) + terminal_event = TaskStatusUpdateEvent( + task_id="task-se", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + final=True, + ) + mock_a2a_client.responses.extend([ + (working_task, status_event), + (terminal_task, terminal_event), + ]) + + stream = a2a_agent.run("hello", stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + + status_update = updates[0] + assert status_update.additional_properties["a2a_metadata"]["msg_key"] == "msg_val" + assert status_update.additional_properties["a2a_metadata"]["event_key"] == "event_val" + + +async def test_history_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Metadata on a history Message should appear on response.additional_properties.""" + task = Task( + id="task-hist", + context_id="ctx", + status=TaskStatus(state=TaskState.completed), + history=[ + A2AMessage( + message_id="h1", + role=A2ARole.agent, + parts=[Part(root=TextPart(text="reply"))], + metadata={"history_key": "history_value"}, + ), + ], + ) + mock_a2a_client.responses.append((task, None)) + + response = await a2a_agent.run("go") + assert response.additional_properties["a2a_metadata"]["history_key"] == "history_value" + + +async def test_continuation_token_update_carries_task_metadata( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """In-progress tasks with background=True should propagate task metadata.""" + task = Task( + id="task-cont", + context_id="ctx", + status=TaskStatus(state=TaskState.working), + metadata={"bg_key": "bg_value"}, + ) + mock_a2a_client.responses.append((task, None)) + + response = await a2a_agent.run("go", background=True) + assert response.continuation_token is not None + assert response.additional_properties["a2a_metadata"]["bg_key"] == "bg_value" + + +async def test_none_metadata_leaves_additional_properties_empty( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """When A2A types have no metadata, additional_properties should remain empty/default.""" + msg = A2AMessage( + message_id="msg-none", + role=A2ARole.agent, + parts=[Part(root=TextPart(text="no meta"))], + ) + mock_a2a_client.responses.append(msg) + + response = await a2a_agent.run("hello") + assert not response.additional_properties + + +# endregion 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 7d5bfc951b..c787de5167 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py @@ -9,6 +9,7 @@ from ._client import AGUIChatClient from ._endpoint import add_agent_framework_fastapi_endpoint from ._event_converters import AGUIEventConverter from ._http_service import AGUIHttpService +from ._state import state_update from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata from ._workflow import AgentFrameworkWorkflow, WorkflowFactory @@ -34,5 +35,6 @@ __all__ = [ "PredictStateConfig", "RunMetadata", "DEFAULT_TAGS", + "state_update", "__version__", ] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 81d5fadbbe..58236cdf0e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import logging +from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any, cast @@ -31,6 +32,7 @@ from ag_ui.core import ( from agent_framework import Content from ._orchestration._predictive_state import PredictiveStateHandler +from ._state import TOOL_RESULT_STATE_KEY from ._utils import generate_event_id, make_json_safe logger = logging.getLogger(__name__) @@ -233,16 +235,66 @@ def _emit_tool_call( return events +def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: + """Extract a deterministic AG-UI state update from a tool-result ``Content``. + + Tools using :func:`agent_framework_ag_ui.state_update` carry the state + payload in ``additional_properties[TOOL_RESULT_STATE_KEY]`` on the inner + text item produced by ``parse_result``. We also check the outer + function_result content's ``additional_properties`` for robustness. + + If multiple items carry state, they are merged in order so later items + override earlier ones (plain ``dict.update`` semantics). + + Returns: + The merged state dict to apply, or ``None`` if no state update is + present. + """ + merged: dict[str, Any] | None = None + + outer_ap = getattr(content, "additional_properties", None) or {} + outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY) + if isinstance(outer_state, dict): + merged = dict(outer_state) + + for item in content.items or (): + item_ap = getattr(item, "additional_properties", None) or {} + item_state = item_ap.get(TOOL_RESULT_STATE_KEY) + if isinstance(item_state, dict): + if merged is None: + merged = dict(item_state) + else: + merged.update(item_state) + + return merged + + def _emit_tool_result_common( call_id: str, raw_result: Any, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None, + *, + state_update: Mapping[str, Any] | None = None, ) -> list[BaseEvent]: """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result`` (MCP server tool results) delegate to this function. + + Args: + call_id: Tool call identifier. + raw_result: The stringified tool result content sent back to the LLM. + flow: Current ``FlowState``. + predictive_handler: Optional predictive state handler driven by + ``predict_state_config``. + state_update: Optional deterministic state snapshot produced by a tool + returning :func:`agent_framework_ag_ui.state_update`. When present, + it is merged into ``flow.current_state`` and a ``StateSnapshotEvent`` + is emitted after the ``ToolCallResult`` event. When both + ``predictive_handler`` and ``state_update`` are active, predictive + updates are applied first, then the deterministic merge, and a + single coalesced ``StateSnapshotEvent`` is emitted. """ events: list[BaseEvent] = [] @@ -271,8 +323,18 @@ def _emit_tool_result_common( if predictive_handler: predictive_handler.apply_pending_updates() - if flow.current_state: - events.append(StateSnapshotEvent(snapshot=flow.current_state)) + + if state_update: + flow.current_state.update(state_update) + logger.debug( + "Emitted deterministic tool-result StateSnapshotEvent for call_id=%s (keys=%s)", + call_id, + list(state_update.keys()), + ) + + # Emit a single coalesced snapshot when either mechanism updated state. + if (predictive_handler or state_update) and flow.current_state: + events.append(StateSnapshotEvent(snapshot=flow.current_state)) flow.tool_call_id = None flow.tool_call_name = None @@ -295,7 +357,14 @@ def _emit_tool_result( if not content.call_id: return [] raw_result = content.result if content.result is not None else "" - return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler) + state_update = _extract_tool_result_state(content) + return _emit_tool_result_common( + content.call_id, + raw_result, + flow, + predictive_handler, + state_update=state_update, + ) def _emit_approval_request( @@ -460,7 +529,14 @@ def _emit_mcp_tool_result( logger.warning("MCP tool result content missing call_id, skipping") return [] raw_output = content.output if content.output is not None else "" - return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) + state_update = _extract_tool_result_state(content) + return _emit_tool_result_common( + content.call_id, + raw_output, + flow, + predictive_handler, + state_update=state_update, + ) def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_state.py b/python/packages/ag-ui/agent_framework_ag_ui/_state.py new file mode 100644 index 0000000000..efce2988fa --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_state.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Deterministic tool-driven AG-UI state updates. + +Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a +deterministic state update by returning :func:`state_update`. Unlike +``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from +LLM-predicted tool call arguments — ``state_update`` runs *after* the tool +executes, so the AG-UI state always reflects the tool's actual return value. + +See issue https://github.com/microsoft/agent-framework/issues/3167 for the +motivating discussion. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from agent_framework import Content + +__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"] + + +TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__" +"""Reserved ``Content.additional_properties`` key used to carry a tool-driven +state snapshot from a tool return value through to the AG-UI emitter.""" + + +def state_update( + text: str = "", + *, + state: Mapping[str, Any], +) -> Content: + """Build a tool return value that deterministically updates AG-UI shared state. + + Return the result of this helper from an agent tool to push a state update + to AG-UI clients using the actual tool output, rather than LLM-predicted + tool arguments. + + When the AG-UI endpoint emits the tool result, it will: + + * Forward ``text`` to the LLM as the normal ``function_result`` content. + * Merge ``state`` into ``FlowState.current_state``. + * Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult`` + event so frontends observe the updated state deterministically. If + predictive state is enabled, a predictive snapshot may be emitted first. + + Example: + .. code-block:: python + + from agent_framework import tool + from agent_framework_ag_ui import state_update + + + @tool + async def get_weather(city: str) -> Content: + data = await _fetch_weather(city) + return state_update( + text=f"Weather in {city}: {data['temp']}°C {data['conditions']}", + state={"weather": {"city": city, **data}}, + ) + + Args: + text: Text passed back to the LLM as the ``function_result`` content. + Defaults to an empty string for tools whose only output is a state + update. + state: A mapping merged into the AG-UI shared state via JSON-compatible + ``dict.update`` semantics. Nested dicts are replaced, not deep-merged. + + Returns: + A ``Content`` object with ``type="text"``. The state payload rides in + ``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is + extracted by the AG-UI emitter. + + Raises: + TypeError: If ``state`` is not a ``Mapping``. + """ + if not isinstance(state, Mapping): + raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}") + return Content.from_text( + text, + additional_properties={TOOL_RESULT_STATE_KEY: dict(state)}, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py new file mode 100644 index 0000000000..f556af3458 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_state_agent.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Deterministic tool-driven AG-UI state example. + +This sample demonstrates how a tool can push a *deterministic* state update +to the AG-UI frontend based on its actual return value — in contrast to +``predict_state_config`` which fires optimistically from LLM-predicted tool +call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167. + +The :func:`agent_framework_ag_ui.state_update` helper wraps a text result +together with a state snapshot. When a tool returns one of these, the AG-UI +endpoint merges the snapshot into the shared state and emits a +``StateSnapshotEvent`` after the tool result. +""" + +from __future__ import annotations + +from typing import Any + +from agent_framework import Agent, Content, SupportsChatGetResponse, tool +from agent_framework.ag_ui import AgentFrameworkAgent + +from agent_framework_ag_ui import state_update + +# Simulated weather database — in the issue's motivating example the tool +# would instead call a real weather API. +_WEATHER_DB: dict[str, dict[str, Any]] = { + "seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75}, + "san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85}, + "new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60}, + "miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90}, + "chicago": {"temperature": 9, "conditions": "windy", "humidity": 65}, +} + + +@tool +async def get_weather(location: str) -> Content: + """Fetch current weather for a location and push it into AG-UI shared state. + + Unlike ``predict_state_config`` — which derives state optimistically from + LLM-predicted tool call arguments — this tool uses ``state_update`` to + forward the *actual* fetched weather to the frontend. The ``text`` goes + back to the LLM as the normal tool result, and the ``state`` dict is merged + into the AG-UI shared state. + + Args: + location: City name to look up. + + Returns: + A :class:`Content` carrying both the LLM-visible text result and a + deterministic state snapshot. + """ + key = location.lower() + data = _WEATHER_DB.get( + key, + {"temperature": 21, "conditions": "partly cloudy", "humidity": 50}, + ) + weather_record = {"location": location, **data} + return state_update( + text=( + f"The weather in {location} is {data['conditions']} at " + f"{data['temperature']}°C with {data['humidity']}% humidity." + ), + state={"weather": weather_record}, + ) + + +def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: + """Create an AG-UI agent with a deterministic tool-driven state tool.""" + agent = Agent[Any]( + name="weather_state_agent", + instructions=( + "You are a weather assistant. When a user asks about the weather " + "in a city, call the get_weather tool and use its output to give a " + "friendly, concise reply. The tool also updates the shared UI state " + "so the frontend can render a weather card from the `weather` key." + ), + client=client, + tools=[get_weather], + ) + + return AgentFrameworkAgent( + agent=agent, + name="WeatherStateAgent", + description="Weather agent that deterministically updates shared state from tool results.", + state_schema={ + "weather": { + "type": "object", + "description": "Last fetched weather record", + }, + }, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 31a7c47963..4b7d56fba5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -24,6 +24,7 @@ from ..agents.subgraphs_agent import subgraphs_agent from ..agents.task_steps_agent import task_steps_agent_wrapped from ..agents.ui_generator_agent import ui_generator_agent from ..agents.weather_agent import weather_agent +from ..agents.weather_state_agent import weather_state_agent AnthropicClient: type[Any] | None try: @@ -141,6 +142,14 @@ add_agent_framework_fastapi_endpoint( path="/subgraphs", ) +# Deterministic Tool-Driven State - tool returns state_update() to push snapshot +# from actual tool output (see issue #3167). +add_agent_framework_fastapi_endpoint( + app=app, + agent=weather_state_agent(client), + path="/deterministic_state", +) + def main(): """Run the server.""" diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py new file mode 100644 index 0000000000..70bc5c129b --- /dev/null +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py @@ -0,0 +1,267 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Golden event-stream tests for the deterministic tool-driven state scenario. + +Covers issue https://github.com/microsoft/agent-framework/issues/3167 — a tool +returning :func:`agent_framework_ag_ui.state_update` must push a deterministic +``StateSnapshotEvent`` derived from its actual return value, orthogonal to the +optimistic ``predict_state_config`` path. These golden tests pin the user-visible +event stream so additive changes cannot silently regress it. +""" + +from __future__ import annotations + +from typing import Any + +from agent_framework import AgentResponseUpdate, Content +from conftest import StubAgent +from event_stream import EventStream + +from agent_framework_ag_ui import AgentFrameworkAgent, state_update + +STATE_SCHEMA = { + "weather": {"type": "object", "description": "Last fetched weather"}, +} + + +def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent: + stub = StubAgent(updates=updates) + kwargs.setdefault("state_schema", STATE_SCHEMA) + return AgentFrameworkAgent(agent=stub, **kwargs) + + +async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream: + return EventStream([event async for event in agent.run(payload)]) + + +PAYLOAD: dict[str, Any] = { + "thread_id": "thread-det-state", + "run_id": "run-det-state", + "messages": [{"role": "user", "content": "What's the weather in SF?"}], + "state": {"weather": {}}, +} + + +def _tool_call(call_id: str, name: str, arguments: str) -> AgentResponseUpdate: + return AgentResponseUpdate( + contents=[Content.from_function_call(name=name, call_id=call_id, arguments=arguments)], + role="assistant", + ) + + +def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> AgentResponseUpdate: + """Build a function_result update whose inner item carries a state marker. + + This mirrors what the core framework produces when a real ``@tool`` returns + :func:`state_update`: ``parse_result`` keeps the ``Content`` as-is, and + ``Content.from_function_result`` preserves its ``additional_properties`` + inside ``items``. + """ + return AgentResponseUpdate( + contents=[ + Content.from_function_result( + call_id=call_id, + result=[state_update(text=text, state=state)], + ) + ], + role="assistant", + ) + + +# ── Golden stream tests ── + + +async def test_deterministic_state_emits_snapshot_after_tool_result() -> None: + """The happy path: STATE_SNAPSHOT follows TOOL_CALL_RESULT in order.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather in SF: 14°C foggy", + state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}}, + ), + AgentResponseUpdate( + contents=[Content.from_text(text="It's 14°C and foggy in SF.")], + role="assistant", + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + stream.assert_text_messages_balanced() + + # Ordered subsequence: the deterministic STATE_SNAPSHOT must follow the + # TOOL_CALL_RESULT. This is the central contract for #3167. + stream.assert_ordered_types( + [ + "RUN_STARTED", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "STATE_SNAPSHOT", + "RUN_FINISHED", + ] + ) + + # The final STATE_SNAPSHOT must carry the tool-driven state. + snapshot = stream.snapshot() + assert snapshot["weather"] == {"city": "SF", "temp": 14, "conditions": "foggy"} + + +async def test_deterministic_state_does_not_fire_for_plain_tool_result() -> None: + """Regression guard: tools returning plain strings must NOT emit a new STATE_SNAPSHOT. + + The initial STATE_SNAPSHOT fires once from the schema + initial payload + state. A plain (non-state_update) tool result must not add another one. + """ + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + AgentResponseUpdate( + contents=[Content.from_function_result(call_id="call-1", result="14°C foggy")], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_text(text="It's 14°C and foggy.")], + role="assistant", + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + + snapshots = stream.get("STATE_SNAPSHOT") + # Only the initial snapshot (from state_schema + payload state) should exist. + # No deterministic snapshot should have been added by the plain tool result. + assert len(snapshots) == 1, ( + f"Expected exactly 1 STATE_SNAPSHOT (initial only) for plain tool result; " + f"got {len(snapshots)}. Snapshots: {[s.snapshot for s in snapshots]}" + ) + + +async def test_deterministic_state_merges_into_initial_state() -> None: + """The tool-driven snapshot must merge into, not replace, pre-existing state keys.""" + payload = dict(PAYLOAD) + payload["state"] = {"weather": {}, "user_preferences": {"unit": "C"}} + + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather: 14°C", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent(updates, state_schema={**STATE_SCHEMA, "user_preferences": {"type": "object"}}) + stream = await _run(agent, payload) + + stream.assert_bookends() + stream.assert_no_run_error() + + final_snapshot = stream.snapshot() + assert final_snapshot["weather"] == {"city": "SF", "temp": 14} + assert final_snapshot["user_preferences"] == {"unit": "C"}, ( + "Pre-existing state keys must survive the deterministic merge" + ) + + +async def test_deterministic_state_llm_visible_text_is_clean() -> None: + """The LLM-visible TOOL_CALL_RESULT content must not leak the state marker key.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather in SF: 14°C foggy", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + result = stream.first("TOOL_CALL_RESULT") + assert result.content == "Weather in SF: 14°C foggy" + # The marker key must never appear in the content sent back to the LLM. + assert "__ag_ui_tool_result_state__" not in result.content + assert "weather" not in result.content # not as a raw state dump + + +async def test_deterministic_state_multiple_tools_merge_in_order() -> None: + """Two state-updating tools in one run merge in order; later wins on key collisions.""" + updates = [ + _tool_call("call-a", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-a", + text="First result", + state={"weather": {"city": "SF", "temp": 14}, "source": "primary"}, + ), + _tool_call("call-b", "get_weather_refined", '{"city": "SF"}'), + _tool_result_with_state( + "call-b", + text="Refined result", + state={"source": "refined"}, + ), + AgentResponseUpdate( + contents=[Content.from_text(text="Here you go.")], + role="assistant", + ), + ] + agent = _build_agent( + updates, + state_schema={**STATE_SCHEMA, "source": {"type": "string"}}, + ) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_tool_calls_balanced() + stream.assert_no_run_error() + + # Two tool-driven snapshots emitted (one per tool) plus the initial snapshot. + snapshots = stream.get("STATE_SNAPSHOT") + assert len(snapshots) >= 2, f"Expected at least 2 STATE_SNAPSHOTs; got {len(snapshots)}" + + final = stream.snapshot() + assert final["weather"] == {"city": "SF", "temp": 14} + # Later tool must override earlier tool on the shared key. + assert final["source"] == "refined" + + +async def test_deterministic_state_coexists_with_predict_state_config() -> None: + """Predictive state and deterministic state must coexist without clobbering each other.""" + predict_config = { + "draft": { + "tool": "write_draft", + "tool_argument": "body", + } + } + updates = [ + # Predictive tool: its argument "body" populates state.draft optimistically. + _tool_call("call-1", "write_draft", '{"body": "Hello world"}'), + # Then a deterministic tool result landing a different key. + _tool_result_with_state( + "call-1", + text="Draft saved", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent( + updates, + state_schema={**STATE_SCHEMA, "draft": {"type": "string"}}, + predict_state_config=predict_config, + require_confirmation=False, + ) + payload = dict(PAYLOAD) + payload["state"] = {"weather": {}, "draft": ""} + stream = await _run(agent, payload) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + + # The final observed state must contain both the deterministic and predictive contributions. + final = stream.snapshot() + assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}" diff --git a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py index e6f58ef0fd..5ea284c68d 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py @@ -1405,3 +1405,95 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin for content in msg.contents: if content.type == "function_result" and content.call_id == "fake_reject_001": assert False, "Fabricated rejection response leaked as function_result into LLM messages" + + +async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub): + """End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must + emit a deterministic STATE_SNAPSHOT through the full pipeline. + + This test exercises the entire chain that a user would hit in production: + ``FunctionInvocationLayer`` executes the tool, ``FunctionTool.parse_result`` + preserves the returned ``Content`` with its ``additional_properties`` marker, + ``Content.from_function_result`` carries the marker through in ``items``, + and the AG-UI emitter extracts it via ``_extract_tool_result_state`` and + emits the snapshot. A regression anywhere in that chain will fail this test. + """ + from agent_framework import tool + from agent_framework.ag_ui import AgentFrameworkAgent + + from agent_framework_ag_ui import state_update + + @tool(name="get_weather", description="Get current weather for a city.") + async def get_weather(city: str) -> Content: + return state_update( + text=f"Weather in {city}: 14°C foggy", + state={"weather": {"city": city, "temperature": 14, "conditions": "foggy"}}, + ) + + call_count = {"n": 0} + + async def stream_fn( + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + """First turn proposes a tool call; second turn (after tool execution) returns text.""" + call_count["n"] += 1 + if call_count["n"] == 1: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + name="get_weather", + call_id="call-weather-1", + arguments='{"city": "SF"}', + ) + ] + ) + else: + yield ChatResponseUpdate(contents=[Content.from_text(text="It's 14°C and foggy in SF.")]) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + name="weather_agent", + instructions="Answer weather questions.", + tools=[get_weather], + ) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"weather": {"type": "object"}}, + ) + + events: list[Any] = [] + async for event in wrapper.run( + { + "thread_id": "thread-weather", + "run_id": "run-weather", + "messages": [{"role": "user", "content": "What's the weather in SF?"}], + "state": {"weather": {}}, + } + ): + events.append(event) + + types = [e.type for e in events] + + # The tool call must be visible in the stream. + assert "TOOL_CALL_START" in types, f"Missing TOOL_CALL_START in: {types}" + assert "TOOL_CALL_RESULT" in types, f"Missing TOOL_CALL_RESULT in: {types}" + + # A STATE_SNAPSHOT must be emitted after the tool result. + tool_result_idx = types.index("TOOL_CALL_RESULT") + snapshot_indices_after_result = [i for i, t in enumerate(types) if t == "STATE_SNAPSHOT" and i > tool_result_idx] + assert snapshot_indices_after_result, ( + f"Expected a STATE_SNAPSHOT after TOOL_CALL_RESULT (index {tool_result_idx}); got types: {types}" + ) + + # The tool's deterministic snapshot carries the actual fetched weather data. + final_snapshot = events[snapshot_indices_after_result[-1]].snapshot + assert final_snapshot["weather"] == { + "city": "SF", + "temperature": 14, + "conditions": "foggy", + } + + # The LLM-visible tool result must carry the plain text, not the marker key. + tool_result_event = next(e for e in events if e.type == "TOOL_CALL_RESULT") + assert tool_result_event.content == "Weather in SF: 14°C foggy" + assert "__ag_ui_tool_result_state__" not in tool_result_event.content diff --git a/python/packages/ag-ui/tests/ag_ui/test_public_exports.py b/python/packages/ag-ui/tests/ag_ui/test_public_exports.py index 433935fb24..ea570f50a6 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_public_exports.py +++ b/python/packages/ag-ui/tests/ag_ui/test_public_exports.py @@ -18,7 +18,24 @@ def test_core_ag_ui_lazy_exports_include_only_stable_api() -> None: assert hasattr(ag_ui, "AgentFrameworkAgent") assert hasattr(ag_ui, "AGUIChatClient") assert hasattr(ag_ui, "add_agent_framework_fastapi_endpoint") + assert hasattr(ag_ui, "state_update") assert not hasattr(ag_ui, "WorkflowFactory") assert not hasattr(ag_ui, "AGUIRequest") assert not hasattr(ag_ui, "RunMetadata") + + +def test_agent_framework_ag_ui_exports_state_update() -> None: + """Runtime package should export the ``state_update`` helper.""" + from agent_framework_ag_ui import state_update + + assert callable(state_update) + + +def test_core_ag_ui_lazy_exports_include_event_converter_and_http_service() -> None: + """Core facade must expose AGUIEventConverter, AGUIHttpService, and __version__.""" + from agent_framework import ag_ui + + assert hasattr(ag_ui, "AGUIEventConverter") + assert hasattr(ag_ui, "AGUIHttpService") + assert hasattr(ag_ui, "__version__") diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 526a3c33c1..27294d9171 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -2,14 +2,20 @@ """Tests for _run_common.py edge cases.""" +from ag_ui.core import EventType from agent_framework import Content +from agent_framework_ag_ui import state_update +from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler from agent_framework_ag_ui._run_common import ( FlowState, + _emit_mcp_tool_result, _emit_tool_result, _extract_resume_payload, + _extract_tool_result_state, _normalize_resume_interrupts, ) +from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY class TestNormalizeResumeInterrupts: @@ -120,3 +126,223 @@ class TestEmitToolResult: assert "TEXT_MESSAGE_END" in event_types assert flow.message_id is None assert flow.accumulated_text == "" + + +class TestStateUpdateHelper: + """Tests for the public ``state_update`` helper.""" + + def test_builds_text_content_with_state_marker(self): + """state_update returns a text Content carrying state in additional_properties.""" + c = state_update(text="done", state={"weather": {"temp": 14}}) + assert c.type == "text" + assert c.text == "done" + assert c.additional_properties == { + TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}}, + } + + def test_empty_text_is_allowed(self): + """State-only tools can omit the text argument.""" + c = state_update(state={"steps": ["a", "b"]}) + assert c.text == "" + assert c.additional_properties[TOOL_RESULT_STATE_KEY] == {"steps": ["a", "b"]} + + def test_non_mapping_state_raises(self): + """Passing a non-mapping value for state raises TypeError.""" + import pytest + + with pytest.raises(TypeError): + state_update(text="t", state=["not", "a", "mapping"]) # type: ignore[arg-type] + + def test_state_is_copied_defensively(self): + """Mutating the caller's dict after ``state_update`` must not mutate the content.""" + caller_state = {"weather": {"temp": 14}} + c = state_update(text="ok", state=caller_state) + caller_state["weather"]["temp"] = 99 + # The top-level dict was copied, so replacing the key in caller_state + # would not affect the Content, but nested dicts share references — document + # this by asserting only the top-level copy semantics. + assert TOOL_RESULT_STATE_KEY in c.additional_properties + inner = c.additional_properties[TOOL_RESULT_STATE_KEY] + assert inner is not caller_state + + +class TestExtractToolResultState: + """Tests for ``_extract_tool_result_state``.""" + + def test_returns_none_for_plain_string_result(self): + content = Content.from_function_result(call_id="c1", result="plain") + assert _extract_tool_result_state(content) is None + + def test_extracts_state_from_inner_item(self): + tool_return = state_update(text="hi", state={"k": 1}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + assert _extract_tool_result_state(content) == {"k": 1} + + def test_extracts_state_from_outer_additional_properties(self): + """Outer function_result content can also carry state (legacy/advanced use).""" + content = Content.from_function_result( + call_id="c1", + result="hi", + additional_properties={TOOL_RESULT_STATE_KEY: {"k": 1}}, + ) + assert _extract_tool_result_state(content) == {"k": 1} + + def test_merges_multiple_items(self): + a = state_update(text="a", state={"k": 1, "shared": "from_a"}) + b = state_update(text="b", state={"shared": "from_b", "extra": True}) + content = Content.from_function_result(call_id="c1", result=[a, b]) + merged = _extract_tool_result_state(content) + assert merged == {"k": 1, "shared": "from_b", "extra": True} + + def test_ignores_non_dict_marker_value(self): + """A garbled marker value must not break extraction (defensive guard).""" + bad = Content.from_text( + "hi", + additional_properties={TOOL_RESULT_STATE_KEY: "not-a-dict"}, + ) + content = Content.from_function_result(call_id="c1", result=[bad]) + assert _extract_tool_result_state(content) is None + + +class TestEmitToolResultWithState: + """Tests for the deterministic state emission in ``_emit_tool_result``.""" + + def test_emits_state_snapshot_after_tool_call_result(self): + """Tool returning state_update produces a StateSnapshotEvent right after the result.""" + tool_return = state_update( + text="Weather: 14°C", + state={"weather": {"temp": 14, "conditions": "foggy"}}, + ) + content = Content.from_function_result(call_id="call_1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + event_types = [e.type for e in events] + + # Expect TOOL_CALL_END, TOOL_CALL_RESULT, STATE_SNAPSHOT in that order. + assert event_types[0] == EventType.TOOL_CALL_END + assert event_types[1] == EventType.TOOL_CALL_RESULT + state_idx = event_types.index(EventType.STATE_SNAPSHOT) + assert state_idx == 2 + assert events[state_idx].snapshot == {"weather": {"temp": 14, "conditions": "foggy"}} + + def test_updates_flow_current_state(self): + tool_return = state_update(text="", state={"a": 1}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState(current_state={"existing": "value"}) + + _emit_tool_result(content, flow) + + # Existing keys must survive (merge semantics), new keys must be added. + assert flow.current_state == {"existing": "value", "a": 1} + + def test_merge_overrides_existing_key(self): + tool_return = state_update(text="", state={"existing": "new"}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState(current_state={"existing": "old", "other": 1}) + + _emit_tool_result(content, flow) + + assert flow.current_state == {"existing": "new", "other": 1} + + def test_no_state_snapshot_when_result_has_no_state(self): + """Plain tool results must not emit a StateSnapshotEvent.""" + content = Content.from_function_result(call_id="c1", result="plain") + flow = FlowState() + + events = _emit_tool_result(content, flow) + assert all(e.type != EventType.STATE_SNAPSHOT for e in events) + + def test_tool_result_content_text_unchanged(self): + """The text sent to the LLM must not leak the state marker.""" + tool_return = state_update(text="Weather: 14°C", state={"weather": {"temp": 14}}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + assert len(result_events) == 1 + assert result_events[0].content == "Weather: 14°C" + assert TOOL_RESULT_STATE_KEY not in result_events[0].content + + def test_coexists_with_active_predictive_state_handler(self): + """Both predictive and deterministic state produce a single coalesced snapshot. + + Predictive state (``predict_state_config``) and deterministic state + (``state_update``) are two independent mechanisms. When both are active, + a single coalesced ``StateSnapshotEvent`` is emitted containing the + merged result of both contributions. + """ + flow = FlowState(current_state={"preexisting": "value"}) + handler = PredictiveStateHandler( + predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}}, + current_state=flow.current_state, + ) + + tool_return = state_update(text="Draft written", state={"draft_final": True}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + + events = _emit_tool_result(content, flow, predictive_handler=handler) + + # Exactly one coalesced snapshot must be emitted containing all merged keys. + snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT] + assert len(snapshots) == 1 + assert snapshots[0].snapshot["draft_final"] is True + assert snapshots[0].snapshot["preexisting"] == "value" + assert flow.current_state["draft_final"] is True + assert flow.current_state["preexisting"] == "value" + + def test_predictive_and_deterministic_emit_single_snapshot(self): + """When both predictive_handler and state_update are active, only one snapshot is emitted.""" + flow = FlowState(current_state={"existing": "yes"}) + handler = PredictiveStateHandler( + predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}}, + current_state=flow.current_state, + ) + + tool_return = state_update(text="ok", state={"new_key": 42}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + + events = _emit_tool_result(content, flow, predictive_handler=handler) + + snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT] + assert len(snapshots) == 1, f"Expected 1 coalesced snapshot, got {len(snapshots)}" + assert snapshots[0].snapshot == {"existing": "yes", "new_key": 42} + + +class TestEmitMcpToolResultWithState: + """MCP tool results should honour the same state_update marker. + + MCP results come from an external MCP server rather than a locally + executed ``@tool`` function, so they do not flow through ``parse_result`` + and ``content.items`` is typically empty. State is instead carried on the + outer content's ``additional_properties`` (e.g. by middleware that + inspects the MCP output and attaches a marker). ``_extract_tool_result_state`` + supports both locations so this path remains usable. + """ + + def test_mcp_tool_result_emits_state_snapshot_from_additional_properties(self): + content = Content.from_mcp_server_tool_result( + call_id="mcp_1", + output="server result", + additional_properties={TOOL_RESULT_STATE_KEY: {"mcp_ok": True}}, + ) + flow = FlowState() + + events = _emit_mcp_tool_result(content, flow) + event_types = [e.type for e in events] + + assert EventType.TOOL_CALL_END in event_types + assert EventType.TOOL_CALL_RESULT in event_types + assert EventType.STATE_SNAPSHOT in event_types + assert flow.current_state == {"mcp_ok": True} + + def test_mcp_tool_result_without_state_emits_no_snapshot(self): + content = Content.from_mcp_server_tool_result( + call_id="mcp_1", + output="server result", + ) + flow = FlowState() + + events = _emit_mcp_tool_result(content, flow) + assert all(e.type != EventType.STATE_SNAPSHOT for e in events) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 52bb4c3a49..945e5356a4 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import os +import re from pathlib import Path from typing import Annotated, Any from unittest.mock import MagicMock, patch @@ -1503,6 +1504,8 @@ async def test_anthropic_client_integration_function_calling() -> None: @skip_if_anthropic_integration_tests_disabled async def test_anthropic_client_integration_hosted_tools() -> None: """Integration test for hosted tools.""" + import anthropic + client = AnthropicClient() messages = [Message(role="user", contents=["What tools do you have available?"])] @@ -1515,10 +1518,18 @@ async def test_anthropic_client_integration_hosted_tools() -> None: ), ] - response = await client.get_response( - messages=messages, - options={"tools": tools, "max_tokens": 100}, - ) + try: + response = await client.get_response( + messages=messages, + options={"tools": tools, "max_tokens": 100}, + ) + except ( + anthropic.BadRequestError, + anthropic.InternalServerError, + anthropic.APIConnectionError, + anthropic.APITimeoutError, + ) as e: + pytest.skip(f"Upstream MCP server unavailable: {e}") assert response is not None assert response.text is not None @@ -1607,7 +1618,8 @@ async def test_anthropic_client_integration_images() -> None: assert response is not None assert response.messages[0].text is not None - assert "house" in response.messages[0].text.lower() + text = response.messages[0].text.lower() + assert re.search(r"\b(house|home|building|cottage|mansion|villa)\b", text) # Response Format Tests diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py index 1b6257f203..496d95d7c3 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -43,9 +43,34 @@ class CosmosCheckpointStorage: ``FileCheckpointStorage``, allowing full Python object fidelity for complex workflow state while keeping the document structure human-readable. - SECURITY WARNING: Checkpoints use pickle for data serialization. Only load - checkpoints from trusted sources. Loading a malicious checkpoint can execute - arbitrary code. + Security warning: checkpoints use pickle for non-JSON-native values. Loading + checkpoints from untrusted sources is unsafe and can execute arbitrary code + during deserialization. The built-in deserialization restrictions reduce risk, + but they do not make untrusted checkpoints safe to load. Extending + ``allowed_checkpoint_types`` may further increase risk and should only be done + for trusted application types. + + By default, checkpoint deserialization is restricted to a built-in set of safe + Python types (primitives, datetime, uuid, ...) and all ``agent_framework`` + internal types. To allow additional application-specific types, pass them via + the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. + + Example: + + .. code-block:: python + + from azure.identity.aio import DefaultAzureCredential + from agent_framework_azure_cosmos import CosmosCheckpointStorage + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-db", + container_name="checkpoints", + allowed_checkpoint_types=[ + "my_app.models:MyState", + ], + ) The database and container are created automatically on first use if they do not already exist. The container uses partition key @@ -97,6 +122,7 @@ class CosmosCheckpointStorage: container_client: ContainerProxy | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + allowed_checkpoint_types: list[str] | None = None, ) -> None: """Initialize the Azure Cosmos DB checkpoint storage. @@ -129,10 +155,15 @@ class CosmosCheckpointStorage: container_client: Pre-created Cosmos container client. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization. Each entry should be a ``"module:qualname"`` + string (e.g., ``"my_app.models:MyState"``). """ self._cosmos_client: CosmosClient | None = cosmos_client self._container_proxy: ContainerProxy | None = container_client self._owns_client = False + self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) if self._container_proxy is not None: self.database_name: str = database_name or "" @@ -401,8 +432,7 @@ class CosmosCheckpointStorage: partition_key=PartitionKey(path="/workflow_name"), ) - @staticmethod - def _document_to_checkpoint(document: dict[str, Any]) -> WorkflowCheckpoint: + def _document_to_checkpoint(self, document: dict[str, Any]) -> WorkflowCheckpoint: """Convert a Cosmos DB document back to a WorkflowCheckpoint. Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``, @@ -413,7 +443,7 @@ class CosmosCheckpointStorage: cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"} cleaned = {k: v for k, v in document.items() if k not in cosmos_keys} - decoded = decode_checkpoint_value(cleaned) + decoded = decode_checkpoint_value(cleaned, allowed_types=self._allowed_types) return WorkflowCheckpoint.from_dict(decoded) @staticmethod diff --git a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py index 52155d0e21..016220e693 100644 --- a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -6,6 +6,7 @@ import os import uuid from collections.abc import AsyncIterator from contextlib import suppress +from dataclasses import dataclass from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -595,3 +596,142 @@ async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None: finally: with suppress(Exception): await cosmos_client.delete_database(database_name) + + +# --- Tests for allowed_checkpoint_types --- + + +@dataclass +class _AppState: + """Application-defined state type used to test allowed_checkpoint_types.""" + + label: str + count: int + + +_APP_STATE_TYPE_KEY = f"{_AppState.__module__}:{_AppState.__qualname__}" + + +def _make_checkpoint_with_state(state: dict[str, Any]) -> WorkflowCheckpoint: + """Create a checkpoint with custom state for serialization tests.""" + return WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="abc123", + timestamp="2025-01-01T00:00:00+00:00", + state=state, + iteration_count=1, + ) + + +async def test_init_accepts_allowed_checkpoint_types(mock_container: MagicMock) -> None: + """CosmosCheckpointStorage.__init__ accepts allowed_checkpoint_types.""" + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=["some.module:SomeType"], + ) + assert storage is not None + + +async def test_load_allows_builtin_safe_types(mock_container: MagicMock) -> None: + """Built-in safe types load without opt-in via allowed_checkpoint_types.""" + from datetime import datetime, timezone + + checkpoint = _make_checkpoint_with_state({ + "ts": datetime(2025, 1, 1, tzinfo=timezone.utc), + "tags": {1, 2, 3}, + }) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["ts"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["tags"] == {1, 2, 3} + + +async def test_load_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """Application types are blocked when not listed in allowed_checkpoint_types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await storage.load(checkpoint.checkpoint_id) + + +async def test_load_allows_listed_app_type(mock_container: MagicMock) -> None: + """Application types are allowed when listed in allowed_checkpoint_types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=7)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert isinstance(loaded.state["data"], _AppState) + assert loaded.state["data"].label == "ok" + assert loaded.state["data"].count == 7 + + +async def test_list_checkpoints_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """list_checkpoints skips documents with unlisted application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + # The document is skipped (logged as warning) because the type is blocked + assert len(results) == 0 + + +async def test_list_checkpoints_allows_listed_app_type(mock_container: MagicMock) -> None: + """list_checkpoints decodes documents with listed application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=3)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 1 + assert isinstance(results[0].state["data"], _AppState) + + +async def test_get_latest_blocks_unlisted_app_type(mock_container: MagicMock) -> None: + """get_latest raises when the checkpoint contains an unlisted application type.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await storage.get_latest(workflow_name="test-workflow") + + +async def test_get_latest_allows_listed_app_type(mock_container: MagicMock) -> None: + """get_latest decodes checkpoints with listed application types.""" + checkpoint = _make_checkpoint_with_state({"data": _AppState(label="latest", count=9)}) + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage( + container_client=mock_container, + allowed_checkpoint_types=[_APP_STATE_TYPE_KEY], + ) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is not None + assert isinstance(result.state["data"], _AppState) + assert result.state["data"].label == "latest" diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index d6940289ac..30f946435a 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -63,6 +63,8 @@ agent_framework/ - **`SessionContext`** - Context object for session-scoped data during agent runs - **`ContextProvider`** - Base class for context providers (RAG, memory systems) - **`HistoryProvider`** - Base class for conversation history storage +- **`InMemoryHistoryProvider`** - Built-in session-state history provider for local runs +- **`FileHistoryProvider`** - JSON Lines file-backed history provider storing one file per session with one message record per line ### Skills (`_skills.py`) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 497fc1496d..7475b1eb96 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -103,6 +103,7 @@ from ._middleware import ( from ._sessions import ( AgentSession, ContextProvider, + FileHistoryProvider, HistoryProvider, InMemoryHistoryProvider, SessionContext, @@ -318,6 +319,7 @@ __all__ = [ "FanInEdgeGroup", "FanOutEdgeGroup", "FileCheckpointStorage", + "FileHistoryProvider", "FinalT", "FinishReason", "FinishReasonLiteral", diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 6fb698768c..1bda62b5d3 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -47,6 +47,7 @@ class ExperimentalFeature(str, Enum): """ EVALS = "EVALS" + FILE_HISTORY = "FILE_HISTORY" SKILLS = "SKILLS" diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 55d1a10a18..20125f19ff 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -8,16 +8,24 @@ This module provides the core types for the context provider pipeline: - HistoryProvider: Base class for history storage providers - AgentSession: Lightweight session state container - InMemoryHistoryProvider: Built-in in-memory history provider +- FileHistoryProvider: Built-in JSON Lines file history provider """ from __future__ import annotations +import asyncio import copy +import json +import threading import uuid +import weakref from abc import abstractmethod +from base64 import urlsafe_b64encode from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast +from ._feature_stage import ExperimentalFeature, experimental from ._middleware import ChatContext, ChatMiddleware from ._types import AgentResponse, ChatResponse, Message, ResponseStream from .exceptions import ChatClientInvalidResponseException @@ -30,6 +38,17 @@ if TYPE_CHECKING: # Registry of known types for state deserialization _STATE_TYPE_REGISTRY: dict[str, type] = {} +JsonDumps: TypeAlias = Callable[[Any], str | bytes] +JsonLoads: TypeAlias = Callable[[str | bytes], Any] + + +def _default_json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _default_json_loads(value: str | bytes) -> Any: + return json.loads(value) + def _is_middleware_sequence( middleware: MiddlewareTypes | Sequence[MiddlewareTypes], @@ -837,3 +856,247 @@ class InMemoryHistoryProvider(HistoryProvider): return existing = state.get("messages", []) state["messages"] = [*existing, *messages] + + +@experimental(feature_id=ExperimentalFeature.FILE_HISTORY) +class FileHistoryProvider(HistoryProvider): + """File-backed history provider that stores one JSON Lines file per session. + + Each persisted message is written as a single JSON object per line. The + provider does not serialize full session snapshots into the file. By default + it uses the standard library ``json`` module, but callers can inject + alternative ``dumps`` and ``loads`` callables compatible with the JSON + Lines format. + + Security posture: + Persisted history is stored as plaintext JSONL on the local filesystem. + Treat ``storage_path`` as trusted application storage, not as a secret + store. Encoded fallback filenames and resolved-path validation help + prevent path traversal via ``session_id``, but they do not encrypt file + contents or provide cross-process / cross-host locking. Use OS-level + file permissions, trusted directories, and carefully review what agent + or tool output is allowed to be persisted. + """ + + DEFAULT_SOURCE_ID: ClassVar[str] = "file_history" + DEFAULT_SESSION_FILE_STEM: ClassVar[str] = "default" + FILE_EXTENSION: ClassVar[str] = ".jsonl" + _FILE_LOCK_STRIPE_COUNT: ClassVar[int] = 64 + _ENCODED_SESSION_PREFIX: ClassVar[str] = "~session-" + _FILE_WRITE_LOCKS: ClassVar[tuple[threading.Lock, ...]] = tuple( + threading.Lock() for _ in range(_FILE_LOCK_STRIPE_COUNT) + ) + _WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", + }) + + def __init__( + self, + storage_path: str | Path, + *, + source_id: str = DEFAULT_SOURCE_ID, + load_messages: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: set[str] | None = None, + store_outputs: bool = True, + skip_excluded: bool = False, + dumps: JsonDumps | None = None, + loads: JsonLoads | None = None, + ) -> None: + """Initialize the file history provider. + + Args: + storage_path: Directory path where session history files will be stored. + + Keyword Args: + source_id: Unique identifier for this provider instance. + load_messages: Whether to load messages before invocation. + store_inputs: Whether to store input messages. + store_context_messages: Whether to store context from other providers. + store_context_from: If set, only store context from these source_ids. + store_outputs: Whether to store response messages. + skip_excluded: When True, ``get_messages`` omits messages whose + ``additional_properties["_excluded"]`` is truthy. + dumps: Callable that serializes a message payload dict to JSON text + or UTF-8 bytes. The returned JSON must fit on a single line. + loads: Callable that deserializes JSON text or bytes back to a + message payload dict. + """ + super().__init__( + source_id=source_id, + load_messages=load_messages, + store_inputs=store_inputs, + store_context_messages=store_context_messages, + store_context_from=store_context_from, + store_outputs=store_outputs, + ) + self.storage_path = Path(storage_path) + self.storage_path.mkdir(parents=True, exist_ok=True) + self._storage_root = self.storage_path.resolve() + self.skip_excluded = skip_excluded + self.dumps = dumps or _default_json_dumps + self.loads = loads or _default_json_loads + self._async_write_locks_by_loop: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, + tuple[asyncio.Lock, ...], + ] = weakref.WeakKeyDictionary() + + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: + """Retrieve messages from the session's JSON Lines file.""" + del state, kwargs + file_path = self._session_file_path(session_id) + async_lock = self._session_async_write_lock(file_path) + thread_lock = self._session_write_lock(file_path) + + def _read_messages() -> list[Message]: + with thread_lock: + if not file_path.exists(): + return [] + + messages: list[Message] = [] + with file_path.open(encoding="utf-8") as file_handle: + for line_number, line in enumerate(file_handle, start=1): + serialized = line.strip() + if not serialized: + continue + try: + payload = self.loads(serialized) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Failed to deserialize history line {line_number} from '{file_path}'." + ) from exc + if not isinstance(payload, Mapping): + raise ValueError( + f"History line {line_number} in '{file_path}' did not deserialize to a mapping." + ) + + try: + message = Message.from_dict(dict(cast(Mapping[str, Any], payload))) + except ValueError as exc: + raise ValueError( + f"History line {line_number} in '{file_path}' is not a valid Message payload." + ) from exc + messages.append(message) + return messages + + async with async_lock: + messages = await asyncio.to_thread(_read_messages) + if self.skip_excluded: + messages = [m for m in messages if not m.additional_properties.get("_excluded", False)] + return messages + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Append messages to the session's JSON Lines file.""" + del state, kwargs + if not messages: + return + + file_path = self._session_file_path(session_id) + async_lock = self._session_async_write_lock(file_path) + file_lock = self._session_write_lock(file_path) + + def _append_messages() -> None: + with file_lock, file_path.open("a", encoding="utf-8") as file_handle: + for message in messages: + file_handle.write(f"{self._serialize_message(message)}\n") + + async with async_lock: + await asyncio.to_thread(_append_messages) + + def _serialize_message(self, message: Message) -> str: + """Serialize a message payload to a single JSON Lines record.""" + serialized = self.dumps(message.to_dict()) + if isinstance(serialized, bytes): + serialized_text = serialized.decode("utf-8") + elif isinstance(serialized, str): + serialized_text = serialized + else: + raise TypeError("FileHistoryProvider.dumps must return str or bytes.") + + if "\n" in serialized_text or "\r" in serialized_text: + raise ValueError("FileHistoryProvider.dumps must return single-line JSON for JSON Lines storage.") + return serialized_text + + def _session_file_path(self, session_id: str | None) -> Path: + """Resolve the on-disk history file path for a session.""" + file_path = (self._storage_root / f"{self._session_file_stem(session_id)}{self.FILE_EXTENSION}").resolve() + if not file_path.is_relative_to(self._storage_root): + raise ValueError(f"Session history path escaped storage directory: {session_id!r}") + return file_path + + def _session_file_stem(self, session_id: str | None) -> str: + """Return the filename stem for a session.""" + raw_session_id = session_id or self.DEFAULT_SESSION_FILE_STEM + if self._is_literal_session_file_stem_safe(raw_session_id): + return raw_session_id + + encoded_session_id = urlsafe_b64encode(raw_session_id.encode("utf-8")).decode("ascii").rstrip("=") + return f"{self._ENCODED_SESSION_PREFIX}{encoded_session_id or self.DEFAULT_SESSION_FILE_STEM}" + + def _session_async_write_lock(self, file_path: Path) -> asyncio.Lock: + """Return the event-loop-local async lock for a session history file.""" + loop = asyncio.get_running_loop() + locks = self._async_write_locks_by_loop.get(loop) + if locks is None: + locks = tuple(asyncio.Lock() for _ in range(self._FILE_LOCK_STRIPE_COUNT)) + self._async_write_locks_by_loop[loop] = locks + return locks[self._lock_index(file_path)] + + @classmethod + def _session_write_lock(cls, file_path: Path) -> threading.Lock: + """Return the process-local thread lock for a session history file.""" + return cls._FILE_WRITE_LOCKS[cls._lock_index(file_path)] + + @classmethod + def _lock_index(cls, file_path: Path) -> int: + """Map a session history file to a bounded lock stripe.""" + return hash(file_path) % cls._FILE_LOCK_STRIPE_COUNT + + @classmethod + def _is_literal_session_file_stem_safe(cls, session_id: str) -> bool: + """Return whether the session ID can be used directly as a filename stem.""" + if ( + not session_id + or session_id.startswith(".") + or session_id.endswith((" ", ".")) + or session_id.upper() in cls._WINDOWS_RESERVED_FILE_STEMS + ): + return False + if any(ord(character) < 32 for character in session_id): + return False + return all(character.isalnum() or character in "._-" for character in session_id) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 5c99dbaa60..d371291b21 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -486,8 +486,8 @@ YAML_KV_RE = re.compile( ) # Validates skill names: lowercase letters, numbers, hyphens only; -# must not start or end with a hyphen. -VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$") +# must not start or end with a hyphen, and must not contain consecutive hyphens. +VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$") # Default system prompt template for advertising available skills to the model. # Use {skills} as the placeholder for the generated skills XML list. @@ -1156,7 +1156,8 @@ def _validate_skill_metadata( if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name): return ( f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, " - "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen." + "using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen " + "or contain consecutive hyphens." ) if not description or not description.strip(): @@ -1241,6 +1242,17 @@ def _read_and_parse_skill_file( return None name, description = result + + dir_name = Path(skill_dir_path).name + if name != dir_name: + logger.error( + "SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.", + skill_file, + name, + dir_name, + ) + return None + return name, description, content diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 87799d0848..584c1f0110 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2816,6 +2816,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): cleanup_hooks if cleanup_hooks is not None else [] ) self._cleanup_run: bool = False + self._stream_error: Exception | None = None self._inner_stream: ResponseStream[Any, Any] | None = None self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None self._wrap_inner: bool = False @@ -2948,8 +2949,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): await self._run_cleanup_hooks() await self.get_final_response() raise - except Exception: - await self._run_cleanup_hooks() + except Exception as exc: + self._stream_error = exc + try: + await self._run_cleanup_hooks() + finally: + self._stream_error = None raise if self._map_update is not None: update = self._map_update(update) # type: ignore[assignment] diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 60c5ec3774..2fd3f35213 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -119,15 +119,11 @@ class WorkflowAgent(BaseAgent): if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types): raise ValueError("Workflow's start executor cannot handle list[Message]") - resolved_context_providers = list(context_providers) if context_providers is not None else [] - if not resolved_context_providers: - resolved_context_providers.append(InMemoryHistoryProvider()) - super().__init__( id=id, name=name, description=description, - context_providers=resolved_context_providers, + context_providers=context_providers, **kwargs, ) self._workflow: Workflow = workflow @@ -261,6 +257,15 @@ class WorkflowAgent(BaseAgent): An AgentResponse representing the workflow execution results. """ input_messages = normalize_messages_input(messages) + + if ( + not any( + provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider) + ) + and session is not None + ): + self.context_providers.append(InMemoryHistoryProvider()) + provider_session = session if provider_session is None and self.context_providers: provider_session = AgentSession() @@ -332,6 +337,15 @@ class WorkflowAgent(BaseAgent): AgentResponseUpdate objects representing the workflow execution progress. """ input_messages = normalize_messages_input(messages) + + if ( + not any( + provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider) + ) + and session is not None + ): + self.context_providers.append(InMemoryHistoryProvider()) + provider_session = session if provider_session is None and self.context_providers: provider_session = AgentSession() diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 2bcc6d355e..626a02199b 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -59,6 +59,62 @@ class AgentExecutorResponse: agent_response: AgentResponse full_conversation: list[Message] + def with_text(self, text: str) -> "AgentExecutorResponse": + """Create a new AgentExecutorResponse with replaced text, preserving the conversation history. + + Use this in custom executors that transform agent output text (e.g. upper-casing, summarising) + when you need downstream AgentExecutors to still have access to the full prior conversation. + + Without this helper, sending a plain ``str`` from a custom executor breaks the context chain: + the downstream ``AgentExecutor.from_str`` handler only adds that one string to its cache and + loses all prior messages. By using ``with_text`` the response type stays + ``AgentExecutorResponse``, so ``AgentExecutor.from_response`` is invoked instead and the full + conversation is preserved. + + Args: + text: The replacement assistant message text. + + Returns: + A new ``AgentExecutorResponse`` whose ``agent_response`` contains a single assistant + message with ``text``, and whose ``full_conversation`` is the prior conversation + (everything before the original agent turn) followed by the new assistant message. + + Example: + .. code-block:: python + + from agent_framework import AgentExecutorResponse, WorkflowContext, executor + + + @executor( + id="upper_case_executor", + input=AgentExecutorResponse, + output=AgentExecutorResponse, + workflow_output=str, + ) + async def upper_case( + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorResponse, str], + ) -> None: + upper_text = response.agent_response.text.upper() + await ctx.send_message(response.with_text(upper_text)) + await ctx.yield_output(upper_text) + """ + new_message = Message("assistant", [text]) + new_agent_response = AgentResponse(messages=[new_message]) + + # Strip off the original agent turn and replace with the new text. + n_agent_messages = len(self.agent_response.messages) + prior_messages = ( + self.full_conversation[:-n_agent_messages] if n_agent_messages else list(self.full_conversation) + ) + new_full_conversation = [*prior_messages, new_message] + + return AgentExecutorResponse( + executor_id=self.executor_id, + agent_response=new_agent_response, + full_conversation=new_full_conversation, + ) + class AgentExecutor(Executor): """built-in executor that wraps an agent for handling messages. @@ -183,7 +239,25 @@ class AgentExecutor(Executor): """Accept a raw user prompt string and run the agent. The new string input will be added to the cache which is used as the conversation context for the agent run. + + Warning: + If the upstream executor received an ``AgentExecutorResponse`` but emits a plain + ``str``, this handler will be invoked instead of ``from_response``. This resets + the conversation context because only the new string is added to the cache and + all prior messages from the upstream agent are lost. + + To preserve the full conversation when transforming agent output in a custom + executor, use ``AgentExecutorResponse.with_text(...)`` so that the message type + stays ``AgentExecutorResponse`` and ``from_response`` is called instead. """ + if not self._cache and ctx.source_executor_ids != ["Workflow"]: + logger.warning( + "AgentExecutor '%s': from_str handler invoked with an empty cache. " + "If you are chaining from an AgentExecutor, the upstream custom executor may be " + "emitting a plain str instead of using AgentExecutorResponse.with_text(...), " + "which causes the full conversation context to be lost.", + self.id, + ) self._cache.extend(normalize_messages_input(text)) await self._run_agent_and_emit(ctx) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index f9a940a7db..22b4a1ea24 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -244,10 +244,10 @@ class FileCheckpointStorage: is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows for human-readable checkpoint files while preserving the ability to store complex Python objects. - By default, checkpoint deserialization is restricted to a built-in set of safe - Python types (primitives, datetime, uuid, ...) and all ``agent_framework`` - internal types. To allow additional application-specific types, pass them via - the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. + By default, checkpoint deserialization is restricted to a built-in set of safe Python types + (primitives, datetime, uuid, ...), all ``agent_framework`` internal types, and OpenAI SDK types + (``openai.types``). To allow additional application-specific types, pass them via the + ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. Example:: diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index a25a08c66a..dd1fb3d704 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -10,9 +10,9 @@ This hybrid approach provides: When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a ``RestrictedUnpickler`` is used that limits which classes may be instantiated during deserialization. The default built-in safe set covers common Python -value types (primitives, datetime, uuid, ...) and all ``agent_framework`` -internal types. Callers can extend the set by passing additional -``"module:qualname"`` strings. +value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal +types, and all ``openai.types`` types. Callers can extend the set by passing +additional ``"module:qualname"`` strings. """ from __future__ import annotations @@ -37,6 +37,9 @@ _JSON_NATIVE_TYPES = (str, int, float, bool, type(None)) # Module prefix for framework-internal types that are always allowed _FRAMEWORK_MODULE_PREFIX = "agent_framework." +# Module prefix for OpenAI SDK types that are always allowed +_OPENAI_MODULE_PREFIX = "openai.types." + # Built-in types considered safe for checkpoint deserialization. # Each entry is a ``module:qualname`` string matching the format produced by # :func:`_type_to_key`. These are the classes for which pickle's @@ -84,8 +87,9 @@ class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301 """Unpickler that restricts which classes may be instantiated. Only classes whose ``module:qualname`` key appears in the combined allow - set (built-in safe types + framework types + caller-specified extras) are - permitted. All other classes raise :class:`pickle.UnpicklingError`. + set (built-in safe types + framework types + OpenAI SDK types + + caller-specified extras) are permitted. All other classes raise + :class:`pickle.UnpicklingError`. """ def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None: @@ -99,6 +103,7 @@ class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301 type_key in _BUILTIN_ALLOWED_TYPE_KEYS or type_key in self._allowed_types or module.startswith(_FRAMEWORK_MODULE_PREFIX) + or module.startswith(_OPENAI_MODULE_PREFIX) ): return super().find_class(module, name) # type: ignore[no-any-return] # nosec diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index 038d12cf89..0d46c0daa3 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -268,6 +268,19 @@ def executor( forward references. When provided, takes precedence over introspection from the ``WorkflowContext`` second generic parameter (W_OutT). + Warning: + When placing a custom ``@executor`` **between** two ``AgentExecutor`` nodes, be + careful about the output type. If the custom executor receives an + ``AgentExecutorResponse`` but emits a plain ``str``, the downstream + ``AgentExecutor.from_str`` handler is invoked instead of ``from_response``. + This resets the conversation context because only the new string is added to + the cache and all prior messages from the upstream agent are lost. + + To preserve the full conversation, use + ``AgentExecutorResponse.with_text(new_text)`` to create a new response that + keeps the prior history, and set ``output=AgentExecutorResponse`` on the + decorator. + Returns: A FunctionExecutor instance that can be wired into a Workflow. diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 58050eece9..fc26db8953 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -11,11 +11,11 @@ import logging import types import uuid from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence -from typing import Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload +from .._sessions import ContextProvider from .._types import ResponseStream from ..observability import OtelAttr, capture_exception, create_workflow_span -from ._agent import WorkflowAgent from ._checkpoint import CheckpointStorage from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from ._edge import ( @@ -35,6 +35,9 @@ from ._runner_context import RunnerContext from ._state import State from ._typing_utils import is_instance_of, try_coerce_to_type +if TYPE_CHECKING: + from ._agent import WorkflowAgent + logger = logging.getLogger(__name__) @@ -910,7 +913,14 @@ class Workflow(DictConvertible): return list(output_types) - def as_agent(self, name: str | None = None) -> WorkflowAgent: + def as_agent( + self, + name: str | None = None, + *, + description: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + **kwargs: Any, + ) -> WorkflowAgent: """Create a WorkflowAgent that wraps this workflow. The returned agent converts standard agent inputs (strings, Message, or lists of these) @@ -924,7 +934,10 @@ class Workflow(DictConvertible): initialization will fail with a ValueError. Args: - name: Optional name for the agent. If None, a default name will be generated. + name: Optional name for the agent. Defaults to workflow name. + description: Optional description of the agent. Defaults to workflow description. + context_providers: Optional sequence of context providers for the agent. + **kwargs: Additional keyword arguments passed to BaseAgent. Returns: A WorkflowAgent instance that wraps this workflow. @@ -935,4 +948,10 @@ class Workflow(DictConvertible): # Import here to avoid circular imports from ._agent import WorkflowAgent - return WorkflowAgent(workflow=self, name=name) + return WorkflowAgent( + workflow=self, + name=name if name is not None else self.name, + description=description if description is not None else self.description, + context_providers=context_providers, + **kwargs, + ) diff --git a/python/packages/core/agent_framework/ag_ui/__init__.py b/python/packages/core/agent_framework/ag_ui/__init__.py index 8e1385a26c..91754e01b4 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.py +++ b/python/packages/core/agent_framework/ag_ui/__init__.py @@ -7,10 +7,13 @@ This module lazily re-exports objects from: Supported classes and functions: - AgentFrameworkAgent +- AgentFrameworkWorkflow - AGUIChatClient - AGUIEventConverter - AGUIHttpService - add_agent_framework_fastapi_endpoint +- state_update +- __version__ """ import importlib @@ -23,6 +26,10 @@ _IMPORTS = [ "AgentFrameworkWorkflow", "add_agent_framework_fastapi_endpoint", "AGUIChatClient", + "AGUIEventConverter", + "AGUIHttpService", + "state_update", + "__version__", ] diff --git a/python/packages/core/agent_framework/ag_ui/__init__.pyi b/python/packages/core/agent_framework/ag_ui/__init__.pyi index 17a5b3a4db..1f6636ae81 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.pyi +++ b/python/packages/core/agent_framework/ag_ui/__init__.pyi @@ -8,6 +8,7 @@ from agent_framework_ag_ui import ( AGUIHttpService, __version__, add_agent_framework_fastapi_endpoint, + state_update, ) __all__ = [ @@ -18,4 +19,5 @@ __all__ = [ "AgentFrameworkWorkflow", "__version__", "add_agent_framework_fastapi_endpoint", + "state_update", ] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 8d2eb05136..6998e5994f 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1323,6 +1323,12 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): from ._types import ChatResponse try: + if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] + # Stream errored; skip get_final_response() to avoid firing + # result hooks such as after_run context providers on error + # paths. Capture the error on the span before returning. + capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage] + return response: ChatResponse[Any] = await result_stream.get_final_response() duration = duration_state.get("duration") response_attributes = _get_response_attributes(attributes, response) @@ -1579,6 +1585,12 @@ class AgentTelemetryLayer: from ._types import AgentResponse try: + if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] + # Stream errored; skip get_final_response() to avoid firing + # result hooks such as after_run context providers on error + # paths. Capture the error on the span before returning. + capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage] + return response: AgentResponse[Any] = await result_stream.get_final_response() duration = duration_state.get("duration") response_attributes = _get_response_attributes( diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index e26e1be6c9..e28245d6e7 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -34,14 +34,13 @@ all = [ "mcp>=1.24.0,<2", "agent-framework-a2a", "agent-framework-ag-ui", + "agent-framework-anthropic", "agent-framework-azure-ai-search", "agent-framework-azure-cosmos", - "agent-framework-anthropic", - "agent-framework-openai", - "agent-framework-claude", "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", + "agent-framework-claude", "agent-framework-copilotstudio", "agent-framework-declarative", "agent-framework-devui", @@ -52,6 +51,7 @@ all = [ "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", + "agent-framework-openai", "agent-framework-orchestrations", "agent-framework-purview", "agent-framework-redis", diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index e5eacebfe5..ebb91d0b0d 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -1,7 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import json +import threading +import time from collections.abc import Awaitable, Callable, Sequence +from pathlib import Path +from typing import Any import pytest @@ -10,6 +15,8 @@ from agent_framework import ( AgentSession, ChatContext, ContextProvider, + ExperimentalFeature, + FileHistoryProvider, HistoryProvider, InMemoryHistoryProvider, Message, @@ -505,3 +512,217 @@ class TestInMemoryHistoryProvider: ctx = SessionContext(session_id="s1", input_messages=[]) ctx.extend_messages("custom-source", [Message(role="user", contents=["test"])]) assert "custom-source" in ctx.context_messages + + +class TestFileHistoryProvider: + def test_is_marked_experimental(self) -> None: + assert FileHistoryProvider.__feature_stage__ == "experimental" + assert FileHistoryProvider.__feature_id__ == ExperimentalFeature.FILE_HISTORY.value + assert FileHistoryProvider.__doc__ is not None + assert ".. warning:: Experimental" in FileHistoryProvider.__doc__ + + async def test_stores_and_loads_messages(self, tmp_path: Path) -> None: + from agent_framework import AgentResponse + + provider = FileHistoryProvider(tmp_path) + session = AgentSession(session_id="s1") + + input_message = Message(role="user", contents=["hello"]) + response_message = Message(role="assistant", contents=["hi there"]) + first_context = SessionContext(session_id=session.session_id, input_messages=[input_message]) + + await provider.before_run( # type: ignore[arg-type] + agent=None, + session=session, + context=first_context, + state={}, + ) + first_context._response = AgentResponse(messages=[response_message]) + await provider.after_run( # type: ignore[arg-type] + agent=None, + session=session, + context=first_context, + state={}, + ) + + session_file = provider._session_file_path(session.session_id) + assert session_file.name == "s1.jsonl" + assert session_file.exists() + raw_lines = (await asyncio.to_thread(session_file.read_text, encoding="utf-8")).splitlines() + assert len(raw_lines) == 2 + payloads = [json.loads(line) for line in raw_lines] + assert all(payload["type"] == "message" for payload in payloads) + assert all("session_id" not in payload for payload in payloads) + + second_context = SessionContext( + session_id=session.session_id, input_messages=[Message(role="user", contents=["again"])] + ) + await provider.before_run( # type: ignore[arg-type] + agent=None, + session=session, + context=second_context, + state={}, + ) + loaded = second_context.context_messages.get(provider.source_id, []) + assert len(loaded) == 2 + assert loaded[0].text == "hello" + assert loaded[1].text == "hi there" + + def test_creates_storage_directory(self, tmp_path: Path) -> None: + nested_path = tmp_path / "nested" / "history" + provider = FileHistoryProvider(nested_path) + assert provider.storage_path == nested_path + assert nested_path.exists() + assert nested_path.is_dir() + + async def test_uses_encoded_filename_for_unsafe_session_id(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path) + unsafe_session_id = "../unsafe/session" + + await provider.save_messages(unsafe_session_id, [Message(role="user", contents=["hello"])]) + + session_file = provider._session_file_path(unsafe_session_id) + assert session_file.parent == provider.storage_path + assert session_file.name.startswith("~session-") + assert session_file.suffix == ".jsonl" + assert session_file.exists() + jsonl_files = await asyncio.to_thread( + lambda: sorted(path.name for path in provider.storage_path.glob("*.jsonl")) + ) + assert jsonl_files == [session_file.name] + + async def test_allows_custom_serializers_returning_bytes(self, tmp_path: Path) -> None: + calls: list[str] = [] + + def dumps(payload: object) -> bytes: + calls.append("dumps") + return json.dumps(payload).encode("utf-8") + + def loads(payload: str | bytes) -> object: + calls.append("loads") + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + return json.loads(payload) + + provider = FileHistoryProvider(tmp_path, dumps=dumps, loads=loads) + + await provider.save_messages("custom-serializer", [Message(role="user", contents=["hello"])]) + loaded = await provider.get_messages("custom-serializer") + + assert calls == ["dumps", "loads"] + assert len(loaded) == 1 + assert loaded[0].text == "hello" + + async def test_invalid_jsonl_line_raises(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path) + await asyncio.to_thread(provider._session_file_path("broken").write_text, "{not-json}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Failed to deserialize history line 1"): + await provider.get_messages("broken") + + async def test_missing_session_file_returns_empty_messages(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path) + + loaded = await provider.get_messages("missing") + + assert loaded == [] + + async def test_none_session_id_uses_default_jsonl_file(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path) + + await provider.save_messages(None, [Message(role="user", contents=["hello"])]) + + session_file = provider._session_file_path(None) + assert session_file.name == "default.jsonl" + loaded = await provider.get_messages(None) + assert [message.text for message in loaded] == ["hello"] + + async def test_non_mapping_jsonl_line_raises(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path) + await asyncio.to_thread(provider._session_file_path("non-mapping").write_text, "[1, 2, 3]\n", encoding="utf-8") + + with pytest.raises(ValueError, match="did not deserialize to a mapping"): + await provider.get_messages("non-mapping") + + async def test_skip_excluded_omits_excluded_messages(self, tmp_path: Path) -> None: + provider = FileHistoryProvider(tmp_path, skip_excluded=True) + + await provider.save_messages( + "skip-excluded", + [ + Message(role="user", contents=["keep"]), + Message(role="assistant", contents=["skip"], additional_properties={"_excluded": True}), + ], + ) + + loaded = await provider.get_messages("skip-excluded") + + assert [message.text for message in loaded] == ["keep"] + + async def test_serializer_must_return_single_line_json(self, tmp_path: Path) -> None: + def dumps(payload: object) -> str: + return json.dumps(payload, indent=2) + + provider = FileHistoryProvider(tmp_path, dumps=dumps) + + with pytest.raises(ValueError, match="single-line JSON"): + await provider.save_messages("pretty-json", [Message(role="user", contents=["hello"])]) + + async def test_concurrent_writes_for_same_session_are_locked( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + provider = FileHistoryProvider(tmp_path) + session_id = "shared-session" + file_path = provider._session_file_path(session_id) + real_open = Path.open + write_started = threading.Event() + active_writes = 0 + overlap_detected = False + + class _TrackingFile: + def __init__(self, wrapped: Any) -> None: + self._wrapped = wrapped + + def __enter__(self) -> "_TrackingFile": + self._wrapped.__enter__() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self._wrapped.__exit__(exc_type, exc_val, exc_tb) + + def write(self, data: str) -> int: + nonlocal active_writes, overlap_detected + write_started.set() + active_writes += 1 + overlap_detected = overlap_detected or active_writes > 1 + try: + time.sleep(0.05) + return int(self._wrapped.write(data)) + finally: + active_writes -= 1 + + def __getattr__(self, name: str) -> Any: + return getattr(self._wrapped, name) + + def tracked_open(path: Path, *args: Any, **kwargs: Any) -> Any: + handle = real_open(path, *args, **kwargs) + if path == file_path and args and args[0] == "a": + return _TrackingFile(handle) + return handle + + monkeypatch.setattr(Path, "open", tracked_open) + + first_save = asyncio.create_task(provider.save_messages(session_id, [Message(role="user", contents=["first"])])) + started = await asyncio.to_thread(write_started.wait, 1.0) + assert started + + second_save = asyncio.create_task( + provider.save_messages(session_id, [Message(role="assistant", contents=["second"])]) + ) + await asyncio.gather(first_save, second_save) + + assert not overlap_detected + loaded = await provider.get_messages(session_id) + assert [message.text for message in loaded] == ["first", "second"] diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 16cae57dcd..225b12d9a1 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -296,6 +296,15 @@ class TestDiscoverAndLoadSkills: skills = _discover_file_skills([str(tmp_path)]) assert len(skills) == 0 + def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-dir-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8" + ) + skills = _discover_file_skills([str(tmp_path)]) + assert len(skills) == 0 + def test_deduplicates_skill_names(self, tmp_path: Path) -> None: dir1 = tmp_path / "dir1" dir2 = tmp_path / "dir2" @@ -904,6 +913,11 @@ class TestSkill: provider = SkillsProvider(skills=[invalid_skill]) assert len(provider._skills) == 0 + def test_name_with_consecutive_hyphens_skipped(self) -> None: + invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body") + provider = SkillsProvider(skills=[invalid_skill]) + assert len(provider._skills) == 0 + def test_name_too_long_skipped(self) -> None: invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body") provider = SkillsProvider(skills=[invalid_skill]) @@ -1421,6 +1435,11 @@ class TestValidateSkillMetadata: assert result is not None assert "invalid name" in result + def test_name_with_consecutive_hyphens(self) -> None: + result = _validate_skill_metadata("consecutive--hyphens", "desc", "source") + assert result is not None + assert "invalid name" in result + def test_single_char_name(self) -> None: assert _validate_skill_metadata("a", "desc", "source") is None @@ -1526,6 +1545,15 @@ class TestReadAndParseSkillFile: result = _read_and_parse_skill_file(str(skill_dir)) assert result is None + def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-dir-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8" + ) + result = _read_and_parse_skill_file(str(skill_dir)) + assert result is None + # --------------------------------------------------------------------------- # Tests: _create_resource_element diff --git a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py index c70d8c85c3..77304841b2 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py +++ b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py @@ -216,3 +216,50 @@ def test_restricted_unpickler_raises_pickle_error(): unpickler = _RestrictedUnpickler(pickled, frozenset()) with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"): unpickler.load() + + +def test_restricted_decode_allows_openai_types(): + """OpenAI SDK types are always allowed during restricted deserialization.""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + from openai.types.completion_usage import CompletionUsage + + completion = ChatCompletion( + id="chatcmpl-test", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage(role="assistant", content="hello"), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + ) + encoded = encode_checkpoint_value(completion) + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset()) + + assert isinstance(decoded, ChatCompletion) + assert decoded.id == "chatcmpl-test" + assert decoded.choices[0].message.content == "hello" + + +def test_restricted_decode_allows_openai_response_types(): + """OpenAI Responses API types are always allowed during restricted deserialization.""" + from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails, ResponseUsage + + usage = ResponseUsage( + input_tokens=10, + output_tokens=20, + total_tokens=30, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + encoded = encode_checkpoint_value(usage) + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset()) + + assert isinstance(decoded, ResponseUsage) + assert decoded.input_tokens == 10 + assert decoded.output_tokens == 20 diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index b38b9400a2..5d9ce45018 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -23,6 +23,7 @@ from agent_framework import ( WorkflowBuilder, WorkflowContext, WorkflowRunState, + executor, handler, ) from agent_framework.orchestrations import SequentialBuilder @@ -478,3 +479,90 @@ async def test_from_response_preserves_service_session_id() -> None: assert result.get_outputs() is not None assert spy_agent._captured_service_session_id == "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage] + + +@executor( + id="upper_case_executor", + input=AgentExecutorResponse, + output=AgentExecutorResponse, + workflow_output=str, +) +async def _upper_case_executor( + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorResponse, str], +) -> None: + upper_text = response.agent_response.text.upper() + await ctx.send_message(response.with_text(upper_text)) + await ctx.yield_output(upper_text) + + +async def test_with_text_preserves_full_conversation_through_custom_executor() -> None: + """Custom executor using with_text must preserve the full conversation chain.""" + # Mirrors the reproduction from issue #5246: + # agent1 ("User likes sky red") -> agent2 ("User likes sky blue") -> upper_case -> agent3 ("User likes sky green") + agent1 = AgentExecutor( + _SimpleAgent(id="agent1", name="ContextAgent1", reply_text="User likes sky red"), id="agent1" + ) + agent2 = AgentExecutor( + _SimpleAgent(id="agent2", name="ContextAgent2", reply_text="User likes sky blue"), id="agent2" + ) + agent3 = AgentExecutor( + _SimpleAgent(id="agent3", name="ContextAgent3", reply_text="User likes sky green"), id="agent3" + ) + capturer = _CaptureFullConversation(id="capture") + + wf = ( + WorkflowBuilder(start_executor=agent1, output_executors=[capturer]) + .add_chain([agent1, agent2, _upper_case_executor, agent3, capturer]) + .build() + ) + + result = await wf.run("") + payload = next(o for o in result.get_outputs() if isinstance(o, dict)) + + # The final agent must see the full conversation: user, agent1, UPPER(agent2), agent3 + assert payload["roles"] == ["user", "assistant", "assistant", "assistant"] + assert payload["texts"][1] == "User likes sky red" + assert payload["texts"][2] == "USER LIKES SKY BLUE" + assert payload["texts"][3] == "User likes sky green" + + +async def test_with_text_does_not_mutate_original() -> None: + """with_text returns a new instance; the original must be unmodified.""" + original = AgentExecutorResponse( + executor_id="test_exec", + agent_response=AgentResponse(messages=[Message("assistant", ["original reply"])]), + full_conversation=[Message("user", ["prompt"]), Message("assistant", ["original reply"])], + ) + + new = original.with_text("transformed reply") + + assert new is not original + assert new.agent_response.text == "transformed reply" + assert new.full_conversation[-1].text == "transformed reply" + assert new.full_conversation[-1].role == "assistant" + # Original unchanged + assert original.agent_response.text == "original reply" + assert original.full_conversation[-1].text == "original reply" + + +async def test_with_text_strips_multi_message_agent_turn() -> None: + """When the agent turn has multiple messages (tool calls), with_text strips all of them.""" + tool_call = Message("assistant", [""]) + tool_result = Message("tool", [""]) + final_reply = Message("assistant", ["actual answer"]) + user_msg = Message("user", ["question"]) + + original = AgentExecutorResponse( + executor_id="exec", + agent_response=AgentResponse(messages=[tool_call, tool_result, final_reply]), + full_conversation=[user_msg, tool_call, tool_result, final_reply], + ) + + new = original.with_text("summarised answer") + + # Only the pre-agent-turn messages should remain, plus the replacement + assert len(new.full_conversation) == 2 + assert new.full_conversation[0].text == "question" + assert new.full_conversation[1].text == "summarised answer" + assert new.agent_response.text == "summarised answer" diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index dd2100c1ae..0101a6e8a5 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -14,6 +14,7 @@ from agent_framework import ( AgentSession, Content, Executor, + HistoryProvider, InMemoryHistoryProvider, Message, ResponseStream, @@ -312,6 +313,37 @@ class TestWorkflowAgent: assert isinstance(agent_no_name, WorkflowAgent) assert agent_no_name.workflow is workflow + def test_workflow_as_agent_with_description_and_context_providers(self) -> None: + """Test that Workflow.as_agent() forwards description and context_providers.""" + executor = SimpleExecutor(id="executor1", response_text="Response") + workflow = WorkflowBuilder(start_executor=executor).build() + + history_provider = InMemoryHistoryProvider() + agent = workflow.as_agent( + name="MyAgent", + description="A test agent", + context_providers=[history_provider], + ) + + assert isinstance(agent, WorkflowAgent) + assert agent.name == "MyAgent" + assert agent.description == "A test agent" + assert history_provider in agent.context_providers + + def test_workflow_as_agent_defaults_name_and_description_from_workflow(self) -> None: + """Test that as_agent() defaults name and description to the workflow's own values.""" + executor = SimpleExecutor(id="executor1", response_text="Response") + workflow = WorkflowBuilder( + start_executor=executor, + name="my-workflow", + description="Workflow description", + ).build() + + agent = workflow.as_agent() + + assert agent.name == "my-workflow" + assert agent.description == "Workflow description" + def test_workflow_as_agent_cannot_handle_agent_inputs(self) -> None: """Test that Workflow.as_agent() raises an error if the start executor cannot handle agent inputs.""" @@ -678,6 +710,110 @@ class TestWorkflowAgent: assert agent.context_providers == [explicit_provider] + async def test_no_history_provider_injected_when_session_is_none(self) -> None: + """Test that InMemoryHistoryProvider is NOT injected when session is None.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Session Agent") + + await agent.run("hello") + + assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + async def test_no_history_provider_injected_when_session_is_none_streaming(self) -> None: + """Test that InMemoryHistoryProvider is NOT injected when session is None (streaming).""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_stream_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Session Stream Agent") + + async for _ in agent.run("hello", stream=True): + pass + + assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + async def test_no_injection_when_history_provider_with_load_messages_exists(self) -> None: + """Test that no InMemoryHistoryProvider is injected when an existing HistoryProvider has load_messages=True.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="existing_provider_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + existing_provider = InMemoryHistoryProvider("custom", load_messages=True) + agent = WorkflowAgent( + workflow=workflow, + name="Existing Provider Agent", + context_providers=[existing_provider], + ) + session = AgentSession() + + await agent.run("hello", session=session) + + # Should still have only the original provider + history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)] + assert len(history_providers) == 1 + assert history_providers[0] is existing_provider + + async def test_injection_when_history_provider_with_load_messages_false(self) -> None: + """Test that InMemoryHistoryProvider IS injected when existing HistoryProvider has load_messages=False.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_load_provider_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + audit_provider = InMemoryHistoryProvider("audit", load_messages=False) + agent = WorkflowAgent( + workflow=workflow, + name="Audit Provider Agent", + context_providers=[audit_provider], + ) + session = AgentSession() + + await agent.run("hello", session=session) + + # Should have injected an additional InMemoryHistoryProvider with load_messages=True + history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)] + assert len(history_providers) == 2 + loading_providers = [p for p in history_providers if p.load_messages] + assert len(loading_providers) == 1 + assert isinstance(loading_providers[0], InMemoryHistoryProvider) + + async def test_no_duplicate_injection_on_multiple_runs(self) -> None: + """Test that calling run() multiple times does not keep adding InMemoryHistoryProvider.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Dup Agent") + session = AgentSession() + + await agent.run("first", session=session) + await agent.run("second", session=session) + await agent.run("third", session=session) + + history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)] + assert len(history_providers) == 1 + + async def test_no_duplicate_injection_on_multiple_runs_streaming(self) -> None: + """Test that calling run(stream=True) multiple times does not keep adding InMemoryHistoryProvider.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_stream_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="No Dup Stream Agent") + session = AgentSession() + + async for _ in agent.run("first", stream=True, session=session): + pass + async for _ in agent.run("second", stream=True, session=session): + pass + async for _ in agent.run("third", stream=True, session=session): + pass + + history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)] + assert len(history_providers) == 1 + + async def test_injection_with_session_in_streaming_mode(self) -> None: + """Test that InMemoryHistoryProvider is injected when session is provided in streaming mode.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="stream_inject_test") + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = WorkflowAgent(workflow=workflow, name="Stream Inject Agent") + session = AgentSession() + + async for _ in agent.run("hello", stream=True, session=session): + pass + + assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + async def test_checkpoint_storage_passed_to_workflow(self) -> None: """Test that checkpoint_storage parameter is passed through to the workflow.""" from agent_framework import InMemoryCheckpointStorage 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 fe3c02b6ee..ae7f718d36 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 KE(e,n){for(var r=0;ra[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"]'))a(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"&&a(d)}).observe(document,{childList:!0,subtree:!0});function r(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 a(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function Cp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eh={exports:{}},qi={};/** +function XE(e,n){for(var r=0;ra[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"]'))a(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"&&a(d)}).observe(document,{childList:!0,subtree:!0});function r(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 a(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function _p(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var eh={exports:{}},qi={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ function KE(e,n){for(var r=0;r>>1,C=R[P];if(0>>1;P<$;){var Y=2*(P+1)-1,V=R[Y],J=Y+1,ce=R[J];if(0>l(V,I))Jl(ce,V)?(R[P]=ce,R[J]=I,P=J):(R[P]=V,R[Y]=I,P=Y);else if(Jl(ce,I))R[P]=ce,R[J]=I,P=J;else break e}}return L}function l(R,L){var I=R.sortIndex-L.sortIndex;return I!==0?I:R.id-L.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=[],h=[],g=1,x=null,y=3,b=!1,j=!1,N=!1,S=!1,_=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function M(R){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=R)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function T(R){if(N=!1,M(R),!j)if(r(m)!==null)j=!0,D||(D=!0,G());else{var L=r(h);L!==null&&U(T,L.startTime-R)}}var D=!1,z=-1,H=5,q=-1;function X(){return S?!0:!(e.unstable_now()-qR&&X());){var P=x.callback;if(typeof P=="function"){x.callback=null,y=x.priorityLevel;var C=P(x.expirationTime<=R);if(R=e.unstable_now(),typeof C=="function"){x.callback=C,M(R),L=!0;break t}x===r(m)&&a(m),M(R)}else a(m);x=r(m)}if(x!==null)L=!0;else{var $=r(h);$!==null&&U(T,$.startTime-R),L=!1}}break e}finally{x=null,y=I,b=!1}L=void 0}}finally{L?G():D=!1}}}var G;if(typeof E=="function")G=function(){E(W)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,B=ne.port2;ne.port1.onmessage=W,G=function(){B.postMessage(null)}}else G=function(){_(W,0)};function U(R,L){z=_(function(){R(e.unstable_now())},L)}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(R){R.callback=null},e.unstable_forceFrameRate=function(R){0>R||125P?(R.sortIndex=I,n(h,R),r(m)===null&&R===r(h)&&(N?(A(z),z=-1):N=!0,U(T,I-P))):(R.sortIndex=C,n(m,R),j||b||(j=!0,D||(D=!0,G()))),R},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(R){var L=y;return function(){var I=y;y=L;try{return R.apply(this,arguments)}finally{y=I}}}})(rh)),rh}var vv;function nC(){return vv||(vv=1,sh.exports=tC()),sh.exports}var oh={exports:{}},Jt={};/** + */var gv;function QE(){return gv||(gv=1,(function(e){function n(R,L){var I=R.length;R.push(L);e:for(;0>>1,C=R[$];if(0>>1;$l(V,I))eel(ie,V)?(R[$]=ie,R[ee]=I,$=ee):(R[$]=V,R[G]=I,$=G);else if(eel(ie,I))R[$]=ie,R[ee]=I,$=ee;else break e}}return L}function l(R,L){var I=R.sortIndex-L.sortIndex;return I!==0?I:R.id-L.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=[],h=[],g=1,y=null,x=3,b=!1,S=!1,N=!1,j=!1,_=typeof setTimeout=="function"?setTimeout:null,T=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function A(R){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=R)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function M(R){if(N=!1,A(R),!S)if(r(m)!==null)S=!0,D||(D=!0,X());else{var L=r(h);L!==null&&B(M,L.startTime-R)}}var D=!1,z=-1,H=5,q=-1;function Y(){return j?!0:!(e.unstable_now()-qR&&Y());){var $=y.callback;if(typeof $=="function"){y.callback=null,x=y.priorityLevel;var C=$(y.expirationTime<=R);if(R=e.unstable_now(),typeof C=="function"){y.callback=C,A(R),L=!0;break t}y===r(m)&&a(m),A(R)}else a(m);y=r(m)}if(y!==null)L=!0;else{var P=r(h);P!==null&&B(M,P.startTime-R),L=!1}}break e}finally{y=null,x=I,b=!1}L=void 0}}finally{L?X():D=!1}}}var X;if(typeof E=="function")X=function(){E(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,U=ne.port2;ne.port1.onmessage=K,X=function(){U.postMessage(null)}}else X=function(){_(K,0)};function B(R,L){z=_(function(){R(e.unstable_now())},L)}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(R){R.callback=null},e.unstable_forceFrameRate=function(R){0>R||125$?(R.sortIndex=I,n(h,R),r(m)===null&&R===r(h)&&(N?(T(z),z=-1):N=!0,B(M,I-$))):(R.sortIndex=C,n(m,R),S||b||(S=!0,D||(D=!0,X()))),R},e.unstable_shouldYield=Y,e.unstable_wrapCallback=function(R){var L=x;return function(){var I=x;x=L;try{return R.apply(this,arguments)}finally{x=I}}}})(rh)),rh}var xv;function JE(){return xv||(xv=1,sh.exports=QE()),sh.exports}var oh={exports:{}},tn={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ function KE(e,n){for(var r=0;r"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),oh.exports=sC(),oh.exports}/** + */var yv;function eC(){if(yv)return tn;yv=1;var e=wl();function n(m){var h="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(),oh.exports=eC(),oh.exports}/** * @license React * react-dom-client.production.js * @@ -38,20 +38,20 @@ function KE(e,n){for(var r=0;rC||(t.current=P[C],P[C]=null,C--)}function V(t,s){C++,P[C]=t.current,t.current=s}var J=$(null),ce=$(null),fe=$(null),ee=$(null);function ie(t,s){switch(V(fe,s),V(ce,t),V(J,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?By(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=By(s),t=Vy(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}Y(J),V(J,t)}function ge(){Y(J),Y(ce),Y(fe)}function Ee(t){t.memoizedState!==null&&V(ee,t);var s=J.current,i=Vy(s,t.type);s!==i&&(V(ce,t),V(J,i))}function Ne(t){ce.current===t&&(Y(J),Y(ce)),ee.current===t&&(Y(ee),Pi._currentValue=I)}var ve=Object.prototype.hasOwnProperty,ze=e.unstable_scheduleCallback,re=e.unstable_cancelCallback,Q=e.unstable_shouldYield,me=e.unstable_requestPaint,be=e.unstable_now,Ce=e.unstable_getCurrentPriorityLevel,we=e.unstable_ImmediatePriority,Me=e.unstable_UserBlockingPriority,je=e.unstable_NormalPriority,Se=e.unstable_LowPriority,Ke=e.unstable_IdlePriority,tt=e.log,Be=e.unstable_setDisableYieldValue,_e=null,xe=null;function $e(t){if(typeof tt=="function"&&Be(t),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(_e,t)}catch{}}var Ge=Math.clz32?Math.clz32:_o,qt=Math.log,rn=Math.LN2;function _o(t){return t>>>=0,t===0?32:31-(qt(t)/rn|0)|0}var Jn=256,vs=4194304;function pe(t){var s=t&42;if(s!==0)return s;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 Ae(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,k=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~v,u!==0?p=pe(u):(k&=O,k!==0?p=pe(k):i||(i=O&~t,i!==0&&(p=pe(i))))):(O=u&~v,O!==0?p=pe(O):k!==0?p=pe(k):i||(i=u&~t,i!==0&&(p=pe(i)))),p===0?0:s!==0&&s!==p&&(s&v)===0&&(v=p&-p,i=s&-s,v>=i||v===32&&(i&4194048)!==0)?s:p}function Ie(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function Ot(t,s){switch(t){case 1:case 2:case 4:case 8:case 64:return s+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 s+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 Ft(){var t=Jn;return Jn<<=1,(Jn&4194048)===0&&(Jn=256),t}function Pe(){var t=vs;return vs<<=1,(vs&62914560)===0&&(vs=4194304),t}function ye(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function dt(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function _t(t,s,i,u,p,v){var k=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,F=t.expirationTimes,se=t.hiddenUpdates;for(i=k&~i;0C||(t.current=$[C],$[C]=null,C--)}function V(t,s){C++,$[C]=t.current,t.current=s}var ee=P(null),ie=P(null),ue=P(null),Q=P(null);function ae(t,s){switch(V(ue,s),V(ie,t),V(ee,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?Hy(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=Hy(s),t=Uy(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}G(ee),V(ee,t)}function ge(){G(ee),G(ie),G(ue)}function Se(t){t.memoizedState!==null&&V(Q,t);var s=ee.current,i=Uy(s,t.type);s!==i&&(V(ie,t),V(ee,i))}function we(t){ie.current===t&&(G(ee),G(ie)),Q.current===t&&(G(Q),Pi._currentValue=I)}var ve=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,Le=e.unstable_cancelCallback,nt=e.unstable_shouldYield,le=e.unstable_requestPaint,Ee=e.unstable_now,W=e.unstable_getCurrentPriorityLevel,xe=e.unstable_ImmediatePriority,be=e.unstable_UserBlockingPriority,pe=e.unstable_NormalPriority,ke=e.unstable_LowPriority,Ce=e.unstable_IdlePriority,De=e.log,Ze=e.unstable_setDisableYieldValue,Pe=null,je=null;function Ne(t){if(typeof De=="function"&&Ze(t),je&&typeof je.setStrictMode=="function")try{je.setStrictMode(Pe,t)}catch{}}var _e=Math.clz32?Math.clz32:sn,Ge=Math.log,dt=Math.LN2;function sn(t){return t>>>=0,t===0?32:31-(Ge(t)/dt|0)|0}var Bt=256,bs=4194304;function he(t){var s=t&42;if(s!==0)return s;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 Me(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,k=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~v,u!==0?p=he(u):(k&=O,k!==0?p=he(k):i||(i=O&~t,i!==0&&(p=he(i))))):(O=u&~v,O!==0?p=he(O):k!==0?p=he(k):i||(i=u&~t,i!==0&&(p=he(i)))),p===0?0:s!==0&&s!==p&&(s&v)===0&&(v=p&-p,i=s&-s,v>=i||v===32&&(i&4194048)!==0)?s:p}function $e(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function It(t,s){switch(t){case 1:case 2:case 4:case 8:case 64:return s+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 s+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 Gt(){var t=Bt;return Bt<<=1,(Bt&4194048)===0&&(Bt=256),t}function Ue(){var t=bs;return bs<<=1,(bs&62914560)===0&&(bs=4194304),t}function ye(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function mt(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Ct(t,s,i,u,p,v){var k=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,F=t.expirationTimes,se=t.hiddenUpdates;for(i=k&~i;0)":-1p||F[u]!==se[p]){var ue=` -`+F[u].replace(" at new "," at ");return t.displayName&&ue.includes("")&&(ue=ue.replace("",t.displayName)),ue}while(1<=u&&0<=p);break}}}finally{Xa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?js(i):""}function Zd(t){switch(t.tag){case 26:case 27:case 5:return js(t.type);case 16:return js("Lazy");case 13:return js("Suspense");case 19:return js("SuspenseList");case 0:case 15:return Za(t.type,!1);case 11:return Za(t.type.render,!1);case 1:return Za(t.type,!0);case 31:return js("Activity");default:return""}}function Vl(t){try{var s="";do s+=Zd(t),t=t.return;while(t);return s}catch(i){return` +`);for(p=u=0;up||F[u]!==se[p]){var de=` +`+F[u].replace(" at new "," at ");return t.displayName&&de.includes("")&&(de=de.replace("",t.displayName)),de}while(1<=u&&0<=p);break}}}finally{Xa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?Ss(i):""}function Zd(t){switch(t.tag){case 26:case 27:case 5:return Ss(t.type);case 16:return Ss("Lazy");case 13:return Ss("Suspense");case 19:return Ss("SuspenseList");case 0:case 15:return Za(t.type,!1);case 11:return Za(t.type.render,!1);case 1:return Za(t.type,!0);case 31:return Ss("Activity");default:return""}}function Vl(t){try{var s="";do s+=Zd(t),t=t.return;while(t);return s}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function on(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function ql(t){var s=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(s==="checkbox"||s==="radio")}function Wd(t){var s=ql(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,s),u=""+t[s];if(!t.hasOwnProperty(s)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var p=i.get,v=i.set;return Object.defineProperty(t,s,{configurable:!0,get:function(){return p.call(this)},set:function(k){u=""+k,v.call(this,k)}}),Object.defineProperty(t,s,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(k){u=""+k},stopTracking:function(){t._valueTracker=null,delete t[s]}}}}function ko(t){t._valueTracker||(t._valueTracker=Wd(t))}function Wa(t){if(!t)return!1;var s=t._valueTracker;if(!s)return!0;var i=s.getValue(),u="";return t&&(u=ql(t)?t.checked?"true":"false":t.value),t=u,t!==i?(s.setValue(t),!0):!1}function To(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 Kd=/[\n"\\]/g;function an(t){return t.replace(Kd,function(s){return"\\"+s.charCodeAt(0).toString(16)+" "})}function Vr(t,s,i,u,p,v,k,O){t.name="",k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"?t.type=k:t.removeAttribute("type"),s!=null?k==="number"?(s===0&&t.value===""||t.value!=s)&&(t.value=""+on(s)):t.value!==""+on(s)&&(t.value=""+on(s)):k!=="submit"&&k!=="reset"||t.removeAttribute("value"),s!=null?Ka(t,k,on(s)):i!=null?Ka(t,k,on(i)):u!=null&&t.removeAttribute("value"),p==null&&v!=null&&(t.defaultChecked=!!v),p!=null&&(t.checked=p&&typeof p!="function"&&typeof p!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+on(O):t.removeAttribute("name")}function Fl(t,s,i,u,p,v,k,O){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(t.type=v),s!=null||i!=null){if(!(v!=="submit"&&v!=="reset"||s!=null))return;i=i!=null?""+on(i):"",s=s!=null?""+on(s):i,O||s===t.value||(t.value=s),t.defaultValue=s}u=u??p,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"&&(t.name=k)}function Ka(t,s,i){s==="number"&&To(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function Ss(t,s,i,u){if(t=t.options,s){s={};for(var p=0;p"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),nf=!1;if(_s)try{var Ja={};Object.defineProperty(Ja,"passive",{get:function(){nf=!0}}),window.addEventListener("test",Ja,Ja),window.removeEventListener("test",Ja,Ja)}catch{nf=!1}var tr=null,sf=null,Gl=null;function Yg(){if(Gl)return Gl;var t,s=sf,i=s.length,u,p="value"in tr?tr.value:tr.textContent,v=p.length;for(t=0;t=ni),Qg=" ",Jg=!1;function ex(t,s){switch(t){case"keyup":return w_.indexOf(s.keyCode)!==-1;case"keydown":return s.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function tx(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Do=!1;function j_(t,s){switch(t){case"compositionend":return tx(s);case"keypress":return s.which!==32?null:(Jg=!0,Qg);case"textInput":return t=s.data,t===Qg&&Jg?null:t;default:return null}}function S_(t,s){if(Do)return t==="compositionend"||!cf&&ex(t,s)?(t=Yg(),Gl=sf=tr=null,Do=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(s.ctrlKey||s.altKey||s.metaKey)||s.ctrlKey&&s.altKey){if(s.char&&1=s)return{node:i,offset:s-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=cx(i)}}function dx(t,s){return t&&s?t===s?!0:t&&t.nodeType===3?!1:s&&s.nodeType===3?dx(t,s.parentNode):"contains"in t?t.contains(s):t.compareDocumentPosition?!!(t.compareDocumentPosition(s)&16):!1:!1}function fx(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var s=To(t.document);s instanceof t.HTMLIFrameElement;){try{var i=typeof s.contentWindow.location.href=="string"}catch{i=!1}if(i)t=s.contentWindow;else break;s=To(t.document)}return s}function ff(t){var s=t&&t.nodeName&&t.nodeName.toLowerCase();return s&&(s==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||s==="textarea"||t.contentEditable==="true")}var R_=_s&&"documentMode"in document&&11>=document.documentMode,Oo=null,mf=null,ai=null,hf=!1;function mx(t,s,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;hf||Oo==null||Oo!==To(u)||(u=Oo,"selectionStart"in u&&ff(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}),ai&&oi(ai,u)||(ai=u,u=Lc(mf,"onSelect"),0>=k,p-=k,Cs=1<<32-Ge(s)+p|i<v?v:8;var k=R.T,O={};R.T=O,Jf(t,!1,s,i);try{var F=p(),se=R.S;if(se!==null&&se(O,F),F!==null&&typeof F=="object"&&typeof F.then=="function"){var ue=U_(F,u);wi(t,s,ue,vn(t))}else wi(t,s,u,vn(t))}catch(he){wi(t,s,{then:function(){},status:"rejected",reason:he},vn())}finally{L.p=v,R.T=k}}function Y_(){}function Kf(t,s,i,u){if(t.tag!==5)throw Error(a(476));var p=h0(t).queue;m0(t,p,s,I,i===null?Y_:function(){return p0(t),i(u)})}function h0(t){var s=t.memoizedState;if(s!==null)return s;s={memoizedState:I,baseState:I,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ms,lastRenderedState:I},next:null};var i={};return s.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ms,lastRenderedState:i},next:null},t.memoizedState=s,t=t.alternate,t!==null&&(t.memoizedState=s),s}function p0(t){var s=h0(t).next.queue;wi(t,s,{},vn())}function Qf(){return Qt(Pi)}function g0(){return Mt().memoizedState}function x0(){return Mt().memoizedState}function G_(t){for(var s=t.return;s!==null;){switch(s.tag){case 24:case 3:var i=vn();t=rr(i);var u=or(s,t,i);u!==null&&(bn(u,s,i),pi(u,s,i)),s={cache:kf()},t.payload=s;return}s=s.return}}function X_(t,s,i){var u=vn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},xc(t)?v0(s,i):(i=yf(t,s,i,u),i!==null&&(bn(i,t,u),b0(i,s,u)))}function y0(t,s,i){var u=vn();wi(t,s,i,u)}function wi(t,s,i,u){var p={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(xc(t))v0(s,p);else{var v=t.alternate;if(t.lanes===0&&(v===null||v.lanes===0)&&(v=s.lastRenderedReducer,v!==null))try{var k=s.lastRenderedState,O=v(k,i);if(p.hasEagerState=!0,p.eagerState=O,hn(O,k))return ec(t,s,p,0),yt===null&&Jl(),!1}catch{}finally{}if(i=yf(t,s,p,u),i!==null)return bn(i,t,u),b0(i,s,u),!0}return!1}function Jf(t,s,i,u){if(u={lane:2,revertLane:Mm(),action:u,hasEagerState:!1,eagerState:null,next:null},xc(t)){if(s)throw Error(a(479))}else s=yf(t,i,u,2),s!==null&&bn(s,t,2)}function xc(t){var s=t.alternate;return t===Qe||s!==null&&s===Qe}function v0(t,s){qo=dc=!0;var i=t.pending;i===null?s.next=s:(s.next=i.next,i.next=s),t.pending=s}function b0(t,s,i){if((i&4194048)!==0){var u=s.lanes;u&=t.pendingLanes,i|=u,s.lanes=i,kn(t,i)}}var yc={readContext:Qt,use:mc,useCallback:Et,useContext:Et,useEffect:Et,useImperativeHandle:Et,useLayoutEffect:Et,useInsertionEffect:Et,useMemo:Et,useReducer:Et,useRef:Et,useState:Et,useDebugValue:Et,useDeferredValue:Et,useTransition:Et,useSyncExternalStore:Et,useId:Et,useHostTransitionStatus:Et,useFormState:Et,useActionState:Et,useOptimistic:Et,useMemoCache:Et,useCacheRefresh:Et},w0={readContext:Qt,use:mc,useCallback:function(t,s){return cn().memoizedState=[t,s===void 0?null:s],t},useContext:Qt,useEffect:r0,useImperativeHandle:function(t,s,i){i=i!=null?i.concat([t]):null,gc(4194308,4,l0.bind(null,s,t),i)},useLayoutEffect:function(t,s){return gc(4194308,4,t,s)},useInsertionEffect:function(t,s){gc(4,2,t,s)},useMemo:function(t,s){var i=cn();s=s===void 0?null:s;var u=t();if(to){$e(!0);try{t()}finally{$e(!1)}}return i.memoizedState=[u,s],u},useReducer:function(t,s,i){var u=cn();if(i!==void 0){var p=i(s);if(to){$e(!0);try{i(s)}finally{$e(!1)}}}else p=s;return u.memoizedState=u.baseState=p,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:p},u.queue=t,t=t.dispatch=X_.bind(null,Qe,t),[u.memoizedState,t]},useRef:function(t){var s=cn();return t={current:t},s.memoizedState=t},useState:function(t){t=Gf(t);var s=t.queue,i=y0.bind(null,Qe,s);return s.dispatch=i,[t.memoizedState,i]},useDebugValue:Zf,useDeferredValue:function(t,s){var i=cn();return Wf(i,t,s)},useTransition:function(){var t=Gf(!1);return t=m0.bind(null,Qe,t.queue,!0,!1),cn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,s,i){var u=Qe,p=cn();if(ct){if(i===void 0)throw Error(a(407));i=i()}else{if(i=s(),yt===null)throw Error(a(349));(it&124)!==0||Bx(u,s,i)}p.memoizedState=i;var v={value:i,getSnapshot:s};return p.queue=v,r0(qx.bind(null,u,v,t),[t]),u.flags|=2048,Yo(9,pc(),Vx.bind(null,u,v,i,s),null),i},useId:function(){var t=cn(),s=yt.identifierPrefix;if(ct){var i=ks,u=Cs;i=(u&~(1<<32-Ge(u)-1)).toString(32)+i,s="«"+s+"R"+i,i=fc++,0qe?(Vt=He,He=null):Vt=He.sibling;var lt=oe(K,He,te[qe],de);if(lt===null){He===null&&(He=Vt);break}t&&He&<.alternate===null&&s(K,He),Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt,He=Vt}if(qe===te.length)return i(K,He),ct&&Zr(K,qe),Re;if(He===null){for(;qeqe?(Vt=He,He=null):Vt=He.sibling;var Nr=oe(K,He,lt.value,de);if(Nr===null){He===null&&(He=Vt);break}t&&He&&Nr.alternate===null&&s(K,He),Z=v(Nr,Z,qe),et===null?Re=Nr:et.sibling=Nr,et=Nr,He=Vt}if(lt.done)return i(K,He),ct&&Zr(K,qe),Re;if(He===null){for(;!lt.done;qe++,lt=te.next())lt=he(K,lt.value,de),lt!==null&&(Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt);return ct&&Zr(K,qe),Re}for(He=u(He);!lt.done;qe++,lt=te.next())lt=ae(He,K,qe,lt.value,de),lt!==null&&(t&<.alternate!==null&&He.delete(lt.key===null?qe:lt.key),Z=v(lt,Z,qe),et===null?Re=lt:et.sibling=lt,et=lt);return t&&He.forEach(function(WE){return s(K,WE)}),ct&&Zr(K,qe),Re}function gt(K,Z,te,de){if(typeof te=="object"&&te!==null&&te.type===j&&te.key===null&&(te=te.props.children),typeof te=="object"&&te!==null){switch(te.$$typeof){case y:e:{for(var Re=te.key;Z!==null;){if(Z.key===Re){if(Re=te.type,Re===j){if(Z.tag===7){i(K,Z.sibling),de=p(Z,te.props.children),de.return=K,K=de;break e}}else if(Z.elementType===Re||typeof Re=="object"&&Re!==null&&Re.$$typeof===H&&j0(Re)===Z.type){i(K,Z.sibling),de=p(Z,te.props),ji(de,te),de.return=K,K=de;break e}i(K,Z);break}else s(K,Z);Z=Z.sibling}te.type===j?(de=Gr(te.props.children,K.mode,de,te.key),de.return=K,K=de):(de=nc(te.type,te.key,te.props,null,K.mode,de),ji(de,te),de.return=K,K=de)}return k(K);case b:e:{for(Re=te.key;Z!==null;){if(Z.key===Re)if(Z.tag===4&&Z.stateNode.containerInfo===te.containerInfo&&Z.stateNode.implementation===te.implementation){i(K,Z.sibling),de=p(Z,te.children||[]),de.return=K,K=de;break e}else{i(K,Z);break}else s(K,Z);Z=Z.sibling}de=wf(te,K.mode,de),de.return=K,K=de}return k(K);case H:return Re=te._init,te=Re(te._payload),gt(K,Z,te,de)}if(U(te))return Fe(K,Z,te,de);if(G(te)){if(Re=G(te),typeof Re!="function")throw Error(a(150));return te=Re.call(te),Ve(K,Z,te,de)}if(typeof te.then=="function")return gt(K,Z,vc(te),de);if(te.$$typeof===E)return gt(K,Z,ac(K,te),de);bc(K,te)}return typeof te=="string"&&te!==""||typeof te=="number"||typeof te=="bigint"?(te=""+te,Z!==null&&Z.tag===6?(i(K,Z.sibling),de=p(Z,te),de.return=K,K=de):(i(K,Z),de=bf(te,K.mode,de),de.return=K,K=de),k(K)):i(K,Z)}return function(K,Z,te,de){try{Ni=0;var Re=gt(K,Z,te,de);return Go=null,Re}catch(He){if(He===mi||He===lc)throw He;var et=pn(29,He,null,K.mode);return et.lanes=de,et.return=K,et}finally{}}}var Xo=S0(!0),_0=S0(!1),Dn=$(null),ns=null;function ir(t){var s=t.alternate;V(It,It.current&1),V(Dn,t),ns===null&&(s===null||Vo.current!==null||s.memoizedState!==null)&&(ns=t)}function E0(t){if(t.tag===22){if(V(It,It.current),V(Dn,t),ns===null){var s=t.alternate;s!==null&&s.memoizedState!==null&&(ns=t)}}else lr()}function lr(){V(It,It.current),V(Dn,Dn.current)}function Rs(t){Y(Dn),ns===t&&(ns=null),Y(It)}var It=$(0);function wc(t){for(var s=t;s!==null;){if(s.tag===13){var i=s.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||Vm(i)))return s}else if(s.tag===19&&s.memoizedProps.revealOrder!==void 0){if((s.flags&128)!==0)return s}else if(s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break;for(;s.sibling===null;){if(s.return===null||s.return===t)return null;s=s.return}s.sibling.return=s.return,s=s.sibling}return null}function em(t,s,i,u){s=t.memoizedState,i=i(u,s),i=i==null?s:g({},s,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var tm={enqueueSetState:function(t,s,i){t=t._reactInternals;var u=vn(),p=rr(u);p.payload=s,i!=null&&(p.callback=i),s=or(t,p,u),s!==null&&(bn(s,t,u),pi(s,t,u))},enqueueReplaceState:function(t,s,i){t=t._reactInternals;var u=vn(),p=rr(u);p.tag=1,p.payload=s,i!=null&&(p.callback=i),s=or(t,p,u),s!==null&&(bn(s,t,u),pi(s,t,u))},enqueueForceUpdate:function(t,s){t=t._reactInternals;var i=vn(),u=rr(i);u.tag=2,s!=null&&(u.callback=s),s=or(t,u,i),s!==null&&(bn(s,t,i),pi(s,t,i))}};function C0(t,s,i,u,p,v,k){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,v,k):s.prototype&&s.prototype.isPureReactComponent?!oi(i,u)||!oi(p,v):!0}function k0(t,s,i,u){t=s.state,typeof s.componentWillReceiveProps=="function"&&s.componentWillReceiveProps(i,u),typeof s.UNSAFE_componentWillReceiveProps=="function"&&s.UNSAFE_componentWillReceiveProps(i,u),s.state!==t&&tm.enqueueReplaceState(s,s.state,null)}function no(t,s){var i=s;if("ref"in s){i={};for(var u in s)u!=="ref"&&(i[u]=s[u])}if(t=t.defaultProps){i===s&&(i=g({},i));for(var p in t)i[p]===void 0&&(i[p]=t[p])}return i}var Nc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var s=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(s))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function T0(t){Nc(t)}function A0(t){console.error(t)}function M0(t){Nc(t)}function jc(t,s){try{var i=t.onUncaughtError;i(s.value,{componentStack:s.stack})}catch(u){setTimeout(function(){throw u})}}function R0(t,s,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:s.tag===1?s.stateNode:null})}catch(p){setTimeout(function(){throw p})}}function nm(t,s,i){return i=rr(i),i.tag=3,i.payload={element:null},i.callback=function(){jc(t,s)},i}function D0(t){return t=rr(t),t.tag=3,t}function O0(t,s,i,u){var p=i.type.getDerivedStateFromError;if(typeof p=="function"){var v=u.value;t.payload=function(){return p(v)},t.callback=function(){R0(s,i,u)}}var k=i.stateNode;k!==null&&typeof k.componentDidCatch=="function"&&(t.callback=function(){R0(s,i,u),typeof p!="function"&&(hr===null?hr=new Set([this]):hr.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function W_(t,s,i,u,p){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(s=i.alternate,s!==null&&ui(s,i,p,!0),i=Dn.current,i!==null){switch(i.tag){case 13:return ns===null?Em():i.alternate===null&&St===0&&(St=3),i.flags&=-257,i.flags|=65536,i.lanes=p,u===Mf?i.flags|=16384:(s=i.updateQueue,s===null?i.updateQueue=new Set([u]):s.add(u),km(t,u,p)),!1;case 22:return i.flags|=65536,u===Mf?i.flags|=16384:(s=i.updateQueue,s===null?(s={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=s):(i=s.retryQueue,i===null?s.retryQueue=new Set([u]):i.add(u)),km(t,u,p)),!1}throw Error(a(435,i.tag))}return km(t,u,p),Em(),!1}if(ct)return s=Dn.current,s!==null?((s.flags&65536)===0&&(s.flags|=256),s.flags|=65536,s.lanes=p,u!==Sf&&(t=Error(a(422),{cause:u}),ci(Tn(t,i)))):(u!==Sf&&(s=Error(a(423),{cause:u}),ci(Tn(s,i))),t=t.current.alternate,t.flags|=65536,p&=-p,t.lanes|=p,u=Tn(u,i),p=nm(t.stateNode,u,p),Of(t,p),St!==4&&(St=2)),!1;var v=Error(a(520),{cause:u});if(v=Tn(v,i),Ai===null?Ai=[v]:Ai.push(v),St!==4&&(St=2),s===null)return!0;u=Tn(u,i),i=s;do{switch(i.tag){case 3:return i.flags|=65536,t=p&-p,i.lanes|=t,t=nm(i.stateNode,u,t),Of(i,t),!1;case 1:if(s=i.type,v=i.stateNode,(i.flags&128)===0&&(typeof s.getDerivedStateFromError=="function"||v!==null&&typeof v.componentDidCatch=="function"&&(hr===null||!hr.has(v))))return i.flags|=65536,p&=-p,i.lanes|=p,p=D0(p),O0(p,t,i,u),Of(i,p),!1}i=i.return}while(i!==null);return!1}var z0=Error(a(461)),Ut=!1;function Yt(t,s,i,u){s.child=t===null?_0(s,null,i,u):Xo(s,t.child,i,u)}function I0(t,s,i,u,p){i=i.render;var v=s.ref;if("ref"in u){var k={};for(var O in u)O!=="ref"&&(k[O]=u[O])}else k=u;return Jr(s),u=Pf(t,s,i,k,v,p),O=Hf(),t!==null&&!Ut?(Uf(t,s,p),Ds(t,s,p)):(ct&&O&&Nf(s),s.flags|=1,Yt(t,s,u,p),s.child)}function L0(t,s,i,u,p){if(t===null){var v=i.type;return typeof v=="function"&&!vf(v)&&v.defaultProps===void 0&&i.compare===null?(s.tag=15,s.type=v,$0(t,s,v,u,p)):(t=nc(i.type,null,u,s,s.mode,p),t.ref=s.ref,t.return=s,s.child=t)}if(v=t.child,!um(t,p)){var k=v.memoizedProps;if(i=i.compare,i=i!==null?i:oi,i(k,u)&&t.ref===s.ref)return Ds(t,s,p)}return s.flags|=1,t=Es(v,u),t.ref=s.ref,t.return=s,s.child=t}function $0(t,s,i,u,p){if(t!==null){var v=t.memoizedProps;if(oi(v,u)&&t.ref===s.ref)if(Ut=!1,s.pendingProps=u=v,um(t,p))(t.flags&131072)!==0&&(Ut=!0);else return s.lanes=t.lanes,Ds(t,s,p)}return sm(t,s,i,u,p)}function P0(t,s,i){var u=s.pendingProps,p=u.children,v=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((s.flags&128)!==0){if(u=v!==null?v.baseLanes|i:i,t!==null){for(p=s.child=t.child,v=0;p!==null;)v=v|p.lanes|p.childLanes,p=p.sibling;s.childLanes=v&~u}else s.childLanes=0,s.child=null;return H0(t,s,u,i)}if((i&536870912)!==0)s.memoizedState={baseLanes:0,cachePool:null},t!==null&&ic(s,v!==null?v.cachePool:null),v!==null?$x(s,v):If(),E0(s);else return s.lanes=s.childLanes=536870912,H0(t,s,v!==null?v.baseLanes|i:i,i)}else v!==null?(ic(s,v.cachePool),$x(s,v),lr(),s.memoizedState=null):(t!==null&&ic(s,null),If(),lr());return Yt(t,s,p,i),s.child}function H0(t,s,i,u){var p=Af();return p=p===null?null:{parent:zt._currentValue,pool:p},s.memoizedState={baseLanes:i,cachePool:p},t!==null&&ic(s,null),If(),E0(s),t!==null&&ui(t,s,u,!0),null}function Sc(t,s){var i=s.ref;if(i===null)t!==null&&t.ref!==null&&(s.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(a(284));(t===null||t.ref!==i)&&(s.flags|=4194816)}}function sm(t,s,i,u,p){return Jr(s),i=Pf(t,s,i,u,void 0,p),u=Hf(),t!==null&&!Ut?(Uf(t,s,p),Ds(t,s,p)):(ct&&u&&Nf(s),s.flags|=1,Yt(t,s,i,p),s.child)}function U0(t,s,i,u,p,v){return Jr(s),s.updateQueue=null,i=Hx(s,u,i,p),Px(t),u=Hf(),t!==null&&!Ut?(Uf(t,s,v),Ds(t,s,v)):(ct&&u&&Nf(s),s.flags|=1,Yt(t,s,i,v),s.child)}function B0(t,s,i,u,p){if(Jr(s),s.stateNode===null){var v=$o,k=i.contextType;typeof k=="object"&&k!==null&&(v=Qt(k)),v=new i(u,v),s.memoizedState=v.state!==null&&v.state!==void 0?v.state:null,v.updater=tm,s.stateNode=v,v._reactInternals=s,v=s.stateNode,v.props=u,v.state=s.memoizedState,v.refs={},Rf(s),k=i.contextType,v.context=typeof k=="object"&&k!==null?Qt(k):$o,v.state=s.memoizedState,k=i.getDerivedStateFromProps,typeof k=="function"&&(em(s,i,k,u),v.state=s.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof v.getSnapshotBeforeUpdate=="function"||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(k=v.state,typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount(),k!==v.state&&tm.enqueueReplaceState(v,v.state,null),xi(s,u,v,p),gi(),v.state=s.memoizedState),typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!0}else if(t===null){v=s.stateNode;var O=s.memoizedProps,F=no(i,O);v.props=F;var se=v.context,ue=i.contextType;k=$o,typeof ue=="object"&&ue!==null&&(k=Qt(ue));var he=i.getDerivedStateFromProps;ue=typeof he=="function"||typeof v.getSnapshotBeforeUpdate=="function",O=s.pendingProps!==O,ue||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(O||se!==k)&&k0(s,v,u,k),sr=!1;var oe=s.memoizedState;v.state=oe,xi(s,u,v,p),gi(),se=s.memoizedState,O||oe!==se||sr?(typeof he=="function"&&(em(s,i,he,u),se=s.memoizedState),(F=sr||C0(s,i,F,u,oe,se,k))?(ue||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount()),typeof v.componentDidMount=="function"&&(s.flags|=4194308)):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),s.memoizedProps=u,s.memoizedState=se),v.props=u,v.state=se,v.context=k,u=F):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!1)}else{v=s.stateNode,Df(t,s),k=s.memoizedProps,ue=no(i,k),v.props=ue,he=s.pendingProps,oe=v.context,se=i.contextType,F=$o,typeof se=="object"&&se!==null&&(F=Qt(se)),O=i.getDerivedStateFromProps,(se=typeof O=="function"||typeof v.getSnapshotBeforeUpdate=="function")||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(k!==he||oe!==F)&&k0(s,v,u,F),sr=!1,oe=s.memoizedState,v.state=oe,xi(s,u,v,p),gi();var ae=s.memoizedState;k!==he||oe!==ae||sr||t!==null&&t.dependencies!==null&&oc(t.dependencies)?(typeof O=="function"&&(em(s,i,O,u),ae=s.memoizedState),(ue=sr||C0(s,i,ue,u,oe,ae,F)||t!==null&&t.dependencies!==null&&oc(t.dependencies))?(se||typeof v.UNSAFE_componentWillUpdate!="function"&&typeof v.componentWillUpdate!="function"||(typeof v.componentWillUpdate=="function"&&v.componentWillUpdate(u,ae,F),typeof v.UNSAFE_componentWillUpdate=="function"&&v.UNSAFE_componentWillUpdate(u,ae,F)),typeof v.componentDidUpdate=="function"&&(s.flags|=4),typeof v.getSnapshotBeforeUpdate=="function"&&(s.flags|=1024)):(typeof v.componentDidUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),s.memoizedProps=u,s.memoizedState=ae),v.props=u,v.state=ae,v.context=F,u=ue):(typeof v.componentDidUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||k===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),u=!1)}return v=u,Sc(t,s),u=(s.flags&128)!==0,v||u?(v=s.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:v.render(),s.flags|=1,t!==null&&u?(s.child=Xo(s,t.child,null,p),s.child=Xo(s,null,i,p)):Yt(t,s,i,p),s.memoizedState=v.state,t=s.child):t=Ds(t,s,p),t}function V0(t,s,i,u){return li(),s.flags|=256,Yt(t,s,i,u),s.child}var rm={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function om(t){return{baseLanes:t,cachePool:Ax()}}function am(t,s,i){return t=t!==null?t.childLanes&~i:0,s&&(t|=On),t}function q0(t,s,i){var u=s.pendingProps,p=!1,v=(s.flags&128)!==0,k;if((k=v)||(k=t!==null&&t.memoizedState===null?!1:(It.current&2)!==0),k&&(p=!0,s.flags&=-129),k=(s.flags&32)!==0,s.flags&=-33,t===null){if(ct){if(p?ir(s):lr(),ct){var O=jt,F;if(F=O){e:{for(F=O,O=ts;F.nodeType!==8;){if(!O){O=null;break e}if(F=Vn(F.nextSibling),F===null){O=null;break e}}O=F}O!==null?(s.memoizedState={dehydrated:O,treeContext:Xr!==null?{id:Cs,overflow:ks}:null,retryLane:536870912,hydrationErrors:null},F=pn(18,null,null,0),F.stateNode=O,F.return=s,s.child=F,tn=s,jt=null,F=!0):F=!1}F||Kr(s)}if(O=s.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return Vm(O)?s.lanes=32:s.lanes=536870912,null;Rs(s)}return O=u.children,u=u.fallback,p?(lr(),p=s.mode,O=_c({mode:"hidden",children:O},p),u=Gr(u,p,i,null),O.return=s,u.return=s,O.sibling=u,s.child=O,p=s.child,p.memoizedState=om(i),p.childLanes=am(t,k,i),s.memoizedState=rm,u):(ir(s),im(s,O))}if(F=t.memoizedState,F!==null&&(O=F.dehydrated,O!==null)){if(v)s.flags&256?(ir(s),s.flags&=-257,s=lm(t,s,i)):s.memoizedState!==null?(lr(),s.child=t.child,s.flags|=128,s=null):(lr(),p=u.fallback,O=s.mode,u=_c({mode:"visible",children:u.children},O),p=Gr(p,O,i,null),p.flags|=2,u.return=s,p.return=s,u.sibling=p,s.child=u,Xo(s,t.child,null,i),u=s.child,u.memoizedState=om(i),u.childLanes=am(t,k,i),s.memoizedState=rm,s=p);else if(ir(s),Vm(O)){if(k=O.nextSibling&&O.nextSibling.dataset,k)var se=k.dgst;k=se,u=Error(a(419)),u.stack="",u.digest=k,ci({value:u,source:null,stack:null}),s=lm(t,s,i)}else if(Ut||ui(t,s,i,!1),k=(i&t.childLanes)!==0,Ut||k){if(k=yt,k!==null&&(u=i&-i,u=(u&42)!==0?1:mn(u),u=(u&(k.suspendedLanes|i))!==0?0:u,u!==0&&u!==F.retryLane))throw F.retryLane=u,Lo(t,u),bn(k,t,u),z0;O.data==="$?"||Em(),s=lm(t,s,i)}else O.data==="$?"?(s.flags|=192,s.child=t.child,s=null):(t=F.treeContext,jt=Vn(O.nextSibling),tn=s,ct=!0,Wr=null,ts=!1,t!==null&&(Mn[Rn++]=Cs,Mn[Rn++]=ks,Mn[Rn++]=Xr,Cs=t.id,ks=t.overflow,Xr=s),s=im(s,u.children),s.flags|=4096);return s}return p?(lr(),p=u.fallback,O=s.mode,F=t.child,se=F.sibling,u=Es(F,{mode:"hidden",children:u.children}),u.subtreeFlags=F.subtreeFlags&65011712,se!==null?p=Es(se,p):(p=Gr(p,O,i,null),p.flags|=2),p.return=s,u.return=s,u.sibling=p,s.child=u,u=p,p=s.child,O=t.child.memoizedState,O===null?O=om(i):(F=O.cachePool,F!==null?(se=zt._currentValue,F=F.parent!==se?{parent:se,pool:se}:F):F=Ax(),O={baseLanes:O.baseLanes|i,cachePool:F}),p.memoizedState=O,p.childLanes=am(t,k,i),s.memoizedState=rm,u):(ir(s),i=t.child,t=i.sibling,i=Es(i,{mode:"visible",children:u.children}),i.return=s,i.sibling=null,t!==null&&(k=s.deletions,k===null?(s.deletions=[t],s.flags|=16):k.push(t)),s.child=i,s.memoizedState=null,i)}function im(t,s){return s=_c({mode:"visible",children:s},t.mode),s.return=t,t.child=s}function _c(t,s){return t=pn(22,t,null,s),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function lm(t,s,i){return Xo(s,t.child,null,i),t=im(s,s.pendingProps.children),t.flags|=2,s.memoizedState=null,t}function F0(t,s,i){t.lanes|=s;var u=t.alternate;u!==null&&(u.lanes|=s),Ef(t.return,s,i)}function cm(t,s,i,u,p){var v=t.memoizedState;v===null?t.memoizedState={isBackwards:s,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:p}:(v.isBackwards=s,v.rendering=null,v.renderingStartTime=0,v.last=u,v.tail=i,v.tailMode=p)}function Y0(t,s,i){var u=s.pendingProps,p=u.revealOrder,v=u.tail;if(Yt(t,s,u.children,i),u=It.current,(u&2)!==0)u=u&1|2,s.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=s.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&F0(t,i,s);else if(t.tag===19)F0(t,i,s);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===s)break e;for(;t.sibling===null;){if(t.return===null||t.return===s)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(V(It,u),p){case"forwards":for(i=s.child,p=null;i!==null;)t=i.alternate,t!==null&&wc(t)===null&&(p=i),i=i.sibling;i=p,i===null?(p=s.child,s.child=null):(p=i.sibling,i.sibling=null),cm(s,!1,p,i,v);break;case"backwards":for(i=null,p=s.child,s.child=null;p!==null;){if(t=p.alternate,t!==null&&wc(t)===null){s.child=p;break}t=p.sibling,p.sibling=i,i=p,p=t}cm(s,!0,i,null,v);break;case"together":cm(s,!1,null,null,void 0);break;default:s.memoizedState=null}return s.child}function Ds(t,s,i){if(t!==null&&(s.dependencies=t.dependencies),mr|=s.lanes,(i&s.childLanes)===0)if(t!==null){if(ui(t,s,i,!1),(i&s.childLanes)===0)return null}else return null;if(t!==null&&s.child!==t.child)throw Error(a(153));if(s.child!==null){for(t=s.child,i=Es(t,t.pendingProps),s.child=i,i.return=s;t.sibling!==null;)t=t.sibling,i=i.sibling=Es(t,t.pendingProps),i.return=s;i.sibling=null}return s.child}function um(t,s){return(t.lanes&s)!==0?!0:(t=t.dependencies,!!(t!==null&&oc(t)))}function K_(t,s,i){switch(s.tag){case 3:ie(s,s.stateNode.containerInfo),nr(s,zt,t.memoizedState.cache),li();break;case 27:case 5:Ee(s);break;case 4:ie(s,s.stateNode.containerInfo);break;case 10:nr(s,s.type,s.memoizedProps.value);break;case 13:var u=s.memoizedState;if(u!==null)return u.dehydrated!==null?(ir(s),s.flags|=128,null):(i&s.child.childLanes)!==0?q0(t,s,i):(ir(s),t=Ds(t,s,i),t!==null?t.sibling:null);ir(s);break;case 19:var p=(t.flags&128)!==0;if(u=(i&s.childLanes)!==0,u||(ui(t,s,i,!1),u=(i&s.childLanes)!==0),p){if(u)return Y0(t,s,i);s.flags|=128}if(p=s.memoizedState,p!==null&&(p.rendering=null,p.tail=null,p.lastEffect=null),V(It,It.current),u)break;return null;case 22:case 23:return s.lanes=0,P0(t,s,i);case 24:nr(s,zt,t.memoizedState.cache)}return Ds(t,s,i)}function G0(t,s,i){if(t!==null)if(t.memoizedProps!==s.pendingProps)Ut=!0;else{if(!um(t,i)&&(s.flags&128)===0)return Ut=!1,K_(t,s,i);Ut=(t.flags&131072)!==0}else Ut=!1,ct&&(s.flags&1048576)!==0&&jx(s,rc,s.index);switch(s.lanes=0,s.tag){case 16:e:{t=s.pendingProps;var u=s.elementType,p=u._init;if(u=p(u._payload),s.type=u,typeof u=="function")vf(u)?(t=no(u,t),s.tag=1,s=B0(null,s,u,t,i)):(s.tag=0,s=sm(null,s,u,t,i));else{if(u!=null){if(p=u.$$typeof,p===M){s.tag=11,s=I0(null,s,u,t,i);break e}else if(p===z){s.tag=14,s=L0(null,s,u,t,i);break e}}throw s=B(u)||u,Error(a(306,s,""))}}return s;case 0:return sm(t,s,s.type,s.pendingProps,i);case 1:return u=s.type,p=no(u,s.pendingProps),B0(t,s,u,p,i);case 3:e:{if(ie(s,s.stateNode.containerInfo),t===null)throw Error(a(387));u=s.pendingProps;var v=s.memoizedState;p=v.element,Df(t,s),xi(s,u,null,i);var k=s.memoizedState;if(u=k.cache,nr(s,zt,u),u!==v.cache&&Cf(s,[zt],i,!0),gi(),u=k.element,v.isDehydrated)if(v={element:u,isDehydrated:!1,cache:k.cache},s.updateQueue.baseState=v,s.memoizedState=v,s.flags&256){s=V0(t,s,u,i);break e}else if(u!==p){p=Tn(Error(a(424)),s),ci(p),s=V0(t,s,u,i);break e}else{switch(t=s.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(jt=Vn(t.firstChild),tn=s,ct=!0,Wr=null,ts=!0,i=_0(s,null,u,i),s.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(li(),u===p){s=Ds(t,s,i);break e}Yt(t,s,u,i)}s=s.child}return s;case 26:return Sc(t,s),t===null?(i=Ky(s.type,null,s.pendingProps,null))?s.memoizedState=i:ct||(i=s.type,t=s.pendingProps,u=Pc(fe.current).createElement(i),u[Ht]=s,u[Kt]=t,Xt(u,i,t),Tt(u),s.stateNode=u):s.memoizedState=Ky(s.type,t.memoizedProps,s.pendingProps,t.memoizedState),null;case 27:return Ee(s),t===null&&ct&&(u=s.stateNode=Xy(s.type,s.pendingProps,fe.current),tn=s,ts=!0,p=jt,xr(s.type)?(qm=p,jt=Vn(u.firstChild)):jt=p),Yt(t,s,s.pendingProps.children,i),Sc(t,s),t===null&&(s.flags|=4194304),s.child;case 5:return t===null&&ct&&((p=u=jt)&&(u=_E(u,s.type,s.pendingProps,ts),u!==null?(s.stateNode=u,tn=s,jt=Vn(u.firstChild),ts=!1,p=!0):p=!1),p||Kr(s)),Ee(s),p=s.type,v=s.pendingProps,k=t!==null?t.memoizedProps:null,u=v.children,Hm(p,v)?u=null:k!==null&&Hm(p,k)&&(s.flags|=32),s.memoizedState!==null&&(p=Pf(t,s,V_,null,null,i),Pi._currentValue=p),Sc(t,s),Yt(t,s,u,i),s.child;case 6:return t===null&&ct&&((t=i=jt)&&(i=EE(i,s.pendingProps,ts),i!==null?(s.stateNode=i,tn=s,jt=null,t=!0):t=!1),t||Kr(s)),null;case 13:return q0(t,s,i);case 4:return ie(s,s.stateNode.containerInfo),u=s.pendingProps,t===null?s.child=Xo(s,null,u,i):Yt(t,s,u,i),s.child;case 11:return I0(t,s,s.type,s.pendingProps,i);case 7:return Yt(t,s,s.pendingProps,i),s.child;case 8:return Yt(t,s,s.pendingProps.children,i),s.child;case 12:return Yt(t,s,s.pendingProps.children,i),s.child;case 10:return u=s.pendingProps,nr(s,s.type,u.value),Yt(t,s,u.children,i),s.child;case 9:return p=s.type._context,u=s.pendingProps.children,Jr(s),p=Qt(p),u=u(p),s.flags|=1,Yt(t,s,u,i),s.child;case 14:return L0(t,s,s.type,s.pendingProps,i);case 15:return $0(t,s,s.type,s.pendingProps,i);case 19:return Y0(t,s,i);case 31:return u=s.pendingProps,i=s.mode,u={mode:u.mode,children:u.children},t===null?(i=_c(u,i),i.ref=s.ref,s.child=i,i.return=s,s=i):(i=Es(t.child,u),i.ref=s.ref,s.child=i,i.return=s,s=i),s;case 22:return P0(t,s,i);case 24:return Jr(s),u=Qt(zt),t===null?(p=Af(),p===null&&(p=yt,v=kf(),p.pooledCache=v,v.refCount++,v!==null&&(p.pooledCacheLanes|=i),p=v),s.memoizedState={parent:u,cache:p},Rf(s),nr(s,zt,p)):((t.lanes&i)!==0&&(Df(t,s),xi(s,null,null,i),gi()),p=t.memoizedState,v=s.memoizedState,p.parent!==u?(p={parent:u,cache:u},s.memoizedState=p,s.lanes===0&&(s.memoizedState=s.updateQueue.baseState=p),nr(s,zt,u)):(u=v.cache,nr(s,zt,u),u!==p.cache&&Cf(s,[zt],i,!0))),Yt(t,s,s.pendingProps.children,i),s.child;case 29:throw s.pendingProps}throw Error(a(156,s.tag))}function Os(t){t.flags|=4}function X0(t,s){if(s.type!=="stylesheet"||(s.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!nv(s)){if(s=Dn.current,s!==null&&((it&4194048)===it?ns!==null:(it&62914560)!==it&&(it&536870912)===0||s!==ns))throw hi=Mf,Mx;t.flags|=8192}}function Ec(t,s){s!==null&&(t.flags|=4),t.flags&16384&&(s=t.tag!==22?Pe():536870912,t.lanes|=s,Qo|=s)}function Si(t,s){if(!ct)switch(t.tailMode){case"hidden":s=t.tail;for(var i=null;s!==null;)s.alternate!==null&&(i=s),s=s.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?s||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function Nt(t){var s=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(s)for(var p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags&65011712,u|=p.flags&65011712,p.return=t,p=p.sibling;else for(p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags,u|=p.flags,p.return=t,p=p.sibling;return t.subtreeFlags|=u,t.childLanes=i,s}function Q_(t,s,i){var u=s.pendingProps;switch(jf(s),s.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Nt(s),null;case 1:return Nt(s),null;case 3:return i=s.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),s.memoizedState.cache!==u&&(s.flags|=2048),As(zt),ge(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(ii(s)?Os(s):t===null||t.memoizedState.isDehydrated&&(s.flags&256)===0||(s.flags|=1024,Ex())),Nt(s),null;case 26:return i=s.memoizedState,t===null?(Os(s),i!==null?(Nt(s),X0(s,i)):(Nt(s),s.flags&=-16777217)):i?i!==t.memoizedState?(Os(s),Nt(s),X0(s,i)):(Nt(s),s.flags&=-16777217):(t.memoizedProps!==u&&Os(s),Nt(s),s.flags&=-16777217),null;case 27:Ne(s),i=fe.current;var p=s.type;if(t!==null&&s.stateNode!=null)t.memoizedProps!==u&&Os(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return Nt(s),null}t=J.current,ii(s)?Sx(s):(t=Xy(p,u,i),s.stateNode=t,Os(s))}return Nt(s),null;case 5:if(Ne(s),i=s.type,t!==null&&s.stateNode!=null)t.memoizedProps!==u&&Os(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return Nt(s),null}if(t=J.current,ii(s))Sx(s);else{switch(p=Pc(fe.current),t){case 1:t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=p.createElement("div"),t.innerHTML="