using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using ProverbsAgent.Models; [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by CanvasAgentFactory")] internal sealed class SharedStateAgent : DelegatingAIAgent { private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly AgentState _state; public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions, AgentState state) : base(innerAgent) { _jsonSerializerOptions = jsonSerializerOptions; _state = state; } public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken); } public override async IAsyncEnumerable RunStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions || !properties.TryGetValue("ag_ui_state", out JsonElement state)) { await foreach (var update in InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false)) { yield return update; } yield break; } // Hydrate backend state from frontend (frontend is source of truth) HydrateStateFromFrontend(state); var firstRunOptions = new ChatClientAgentRunOptions { ChatOptions = chatRunOptions.ChatOptions.Clone(), AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses, ContinuationToken = chatRunOptions.ContinuationToken, ChatClientFactory = chatRunOptions.ChatClientFactory, }; // Configure JSON schema response format for structured state output firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema( schemaName: "AgentStateSnapshot", schemaDescription: "A response containing the current Kanban board state with all boards and tasks"); // Filter out tool-related messages from incoming history to avoid "tool_calls must be followed by tool messages" errors var cleanMessages = messages.Where(m => m.Role != ChatRole.Tool && !m.Contents.Any(c => c is FunctionCallContent || c is FunctionResultContent)); ChatMessage stateUpdateMessage = new( ChatRole.System, [ new TextContent("Here is the current state in JSON format:"), new TextContent(state.GetRawText()), new TextContent("The new state is:") ]); var firstRunMessages = cleanMessages.Append(stateUpdateMessage); var allUpdates = new List(); await foreach (var update in InnerAgent.RunStreamingAsync(firstRunMessages, thread, firstRunOptions, cancellationToken).ConfigureAwait(false)) { allUpdates.Add(update); // Yield all non-text updates (tool calls, etc.) bool hasNonTextContent = update.Contents.Any(c => c is not TextContent); if (hasNonTextContent) { yield return update; } } var response = allUpdates.ToAgentRunResponse(); // Serialize the ACTUAL backend state (not the LLM's interpretation) var stateSnapshot = new AgentStateSnapshot { Boards = _state.Boards, ActiveBoardId = _state.ActiveBoardId, LastAction = _state.LastAction }; byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( stateSnapshot, _jsonSerializerOptions.GetTypeInfo(typeof(AgentStateSnapshot))); yield return new AgentRunResponseUpdate { Contents = [new DataContent(stateBytes, "application/json")] }; // Filter out tool-related messages to avoid "tool_calls must be followed by tool messages" errors var nonToolMessages = response.Messages.Where(m => m.Role != ChatRole.Tool && !m.Contents.Any(c => c is FunctionCallContent || c is FunctionResultContent)); var secondRunMessages = cleanMessages.Concat(nonToolMessages).Append( new ChatMessage( ChatRole.System, [new TextContent("Please provide a concise summary of the state changes in at most two sentences.")])); await foreach (var update in InnerAgent.RunStreamingAsync(secondRunMessages, thread, options, cancellationToken).ConfigureAwait(false)) { yield return update; } } /// /// Hydrates the backend state from the frontend state (frontend is source of truth). /// This ensures the backend always starts with the current frontend state on each request. /// private void HydrateStateFromFrontend(JsonElement frontendState) { try { var incomingState = JsonSerializer.Deserialize( frontendState.GetRawText(), _jsonSerializerOptions); if (incomingState != null) { _state.Boards.Clear(); _state.Boards.AddRange(incomingState.Boards); _state.ActiveBoardId = incomingState.ActiveBoardId; _state.LastAction = incomingState.LastAction ?? string.Empty; Console.WriteLine($"🔄 Hydrated state from frontend: {_state.Boards.Count} boards, active: {_state.ActiveBoardId}"); } } catch (JsonException ex) { Console.WriteLine($"⚠️ Failed to hydrate state from frontend: {ex.Message}"); // Keep existing backend state if deserialization fails } } }