using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Services.Agent; /// /// AX Agent execution-prep engine. /// Inspired by the `claw-code` split between input preparation and session execution, /// so the UI layer stops owning message assembly and final assistant commit logic. /// public sealed class AxAgentExecutionEngine { public sealed record PreparedTurn(List Messages); public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt); public sealed record PreparedExecution( ExecutionMode Mode, IReadOnlyList PromptStack, List Messages); public sealed record FinalizedContent(string Content, bool Cancelled, string? FailureReason); public sealed record SessionMutationResult( ChatConversation CurrentConversation, ChatConversation UpdatedConversation); public IReadOnlyList BuildPromptStack( string? conversationSystem, string? slashSystem, string? taskSystem = null) { var prompts = new List(); if (!string.IsNullOrWhiteSpace(conversationSystem)) prompts.Add(conversationSystem.Trim()); if (!string.IsNullOrWhiteSpace(slashSystem)) prompts.Add(slashSystem.Trim()); if (!string.IsNullOrWhiteSpace(taskSystem)) prompts.Add(taskSystem.Trim()); return prompts; } public ExecutionMode ResolveExecutionMode( string runTab, bool streamingEnabled, string resolvedService, string? coworkSystemPrompt, string? codeSystemPrompt) { if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)) return new ExecutionMode(true, false, coworkSystemPrompt); if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) return new ExecutionMode(true, false, codeSystemPrompt); return new ExecutionMode(false, false, null); } public PreparedExecution PrepareExecution( ChatConversation conversation, string runTab, bool streamingEnabled, string resolvedService, string? conversationSystem, string? slashSystem, string? coworkSystemPrompt, string? codeSystemPrompt, string? fileContext = null, IReadOnlyList? images = null) { var mode = ResolveExecutionMode( runTab, streamingEnabled, resolvedService, coworkSystemPrompt, codeSystemPrompt); var promptStack = BuildPromptStack( conversationSystem, slashSystem, mode.TaskSystemPrompt); var preparedTurn = PrepareTurn(conversation, promptStack, fileContext, images); return new PreparedExecution(mode, promptStack, preparedTurn.Messages); } public Task ExecutePreparedAsync( PreparedExecution prepared, Func, CancellationToken, Task> agentLoopRunner, Func, CancellationToken, Task> llmRunner, CancellationToken cancellationToken) { return prepared.Mode.UseAgentLoop ? agentLoopRunner(prepared.Messages, cancellationToken) : llmRunner(prepared.Messages, cancellationToken); } public PreparedTurn PrepareTurn( ChatConversation conversation, IEnumerable systemPrompts, string? fileContext = null, IReadOnlyList? images = null) { var outbound = conversation.Messages .Select(CloneMessage) .ToList(); if (!string.IsNullOrWhiteSpace(fileContext)) { var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); if (lastUserIndex >= 0) outbound[lastUserIndex].Content = (outbound[lastUserIndex].Content ?? string.Empty) + fileContext; } if (images is { Count: > 0 }) { var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); if (lastUserIndex >= 0) outbound[lastUserIndex].Images = images.Select(CloneImage).ToList(); } var promptList = systemPrompts .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) .Select(prompt => prompt!.Trim()) .ToList(); for (var i = promptList.Count - 1; i >= 0; i--) outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] }); return new PreparedTurn(outbound); } public ChatMessage CommitAssistantMessage( ChatSessionStateService? session, ChatConversation conversation, string tab, string content, int promptTokens = 0, int completionTokens = 0, long? responseElapsedMs = null, string? metaRunId = null, ChatStorageService? storage = null) { var assistant = new ChatMessage { Role = "assistant", Content = content, PromptTokens = Math.Max(0, promptTokens), CompletionTokens = Math.Max(0, completionTokens), ResponseElapsedMs = responseElapsedMs is > 0 ? responseElapsedMs : null, MetaRunId = string.IsNullOrWhiteSpace(metaRunId) ? null : metaRunId, }; if (session != null) { session.AppendMessage(tab, assistant, storage); return assistant; } conversation.Messages.Add(assistant); conversation.UpdatedAt = DateTime.Now; return assistant; } public string FinalizeAssistantTurn( ChatSessionStateService? session, ChatConversation conversation, string tab, string? content, int promptTokens = 0, int completionTokens = 0, long? responseElapsedMs = null, string? metaRunId = null, ChatStorageService? storage = null) { var normalized = NormalizeAssistantContentForUi(conversation, tab, content); if (tab is "Cowork" or "Code") conversation.ShowExecutionHistory = false; CommitAssistantMessage(session, conversation, tab, normalized, promptTokens, completionTokens, responseElapsedMs, metaRunId, storage); return normalized; } public FinalizedContent FinalizeExecutionContentForUi(string? currentContent, Exception? error = null, bool cancelled = false) { if (cancelled) { var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!; return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다."); } if (error != null) return new FinalizedContent($"오류: {error.Message}", false, error.Message); return new FinalizedContent(currentContent ?? string.Empty, false, null); } public string NormalizeAssistantContentForUi( ChatConversation conversation, string runTab, string? content) { if (!string.IsNullOrWhiteSpace(content)) return content; var latestEventSummary = conversation.ExecutionEvents? .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)) .OrderByDescending(evt => evt.Timestamp) .Select(evt => evt.Summary.Trim()) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(latestEventSummary)) { return runTab switch { "Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}", "Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}", _ => latestEventSummary, }; } return "(빈 응답)"; } public FinalizedContent FinalizeExecutionContent(string? currentContent, Exception? error = null, bool cancelled = false) { if (cancelled) { var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!; return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다."); } if (error != null) { return new FinalizedContent($"⚠ 오류: {error.Message}", false, error.Message); } return new FinalizedContent(currentContent ?? string.Empty, false, null); } public SessionMutationResult AppendExecutionEvent( ChatSessionStateService session, ChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, AgentEvent evt) { return ApplyConversationMutation( session, storage, activeConversation, activeTab, targetTab, normalizedTarget => session.AppendExecutionEvent(normalizedTarget, evt, null)); } public SessionMutationResult AppendAgentRun( ChatSessionStateService session, ChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, AgentEvent evt, string status, string summary) { return ApplyConversationMutation( session, storage, activeConversation, activeTab, targetTab, normalizedTarget => session.AppendAgentRun(normalizedTarget, evt, status, summary, null)); } public string NormalizeAssistantContent( ChatConversation conversation, string runTab, string? content) { if (!string.IsNullOrWhiteSpace(content)) return content; var latestEventSummary = conversation.ExecutionEvents? .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)) .OrderByDescending(evt => evt.Timestamp) .Select(evt => evt.Summary.Trim()) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(latestEventSummary)) { return runTab switch { "Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}", "Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}", _ => latestEventSummary, }; } return "(빈 응답)"; } private static ChatMessage CloneMessage(ChatMessage source) { return new ChatMessage { Role = source.Role, Content = source.Content, Timestamp = source.Timestamp, MetaRunId = source.MetaRunId, AttachedFiles = source.AttachedFiles?.ToList(), Images = source.Images?.Select(CloneImage).ToList(), }; } private static ImageAttachment CloneImage(ImageAttachment source) { return new ImageAttachment { Base64 = source.Base64, MimeType = source.MimeType, FileName = source.FileName, }; } private static SessionMutationResult ApplyConversationMutation( ChatSessionStateService session, ChatStorageService storage, ChatConversation? activeConversation, string activeTab, string targetTab, Func mutate) { var normalizedTarget = NormalizeTabName(targetTab); var normalizedActive = NormalizeTabName(activeTab); ChatConversation updatedConversation; if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase)) { session.CurrentConversation = updatedConversation = mutate(normalizedTarget); return new SessionMutationResult(updatedConversation, updatedConversation); } var activeSnapshot = activeConversation; var previousSessionConversation = session.CurrentConversation; updatedConversation = mutate(normalizedTarget); if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) { session.CurrentConversation = activeSnapshot; return new SessionMutationResult(activeSnapshot, updatedConversation); } if (previousSessionConversation != null && string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) { session.CurrentConversation = previousSessionConversation; return new SessionMutationResult(previousSessionConversation, updatedConversation); } var activeId = session.GetConversationId(normalizedActive); var restoredConversation = string.IsNullOrWhiteSpace(activeId) ? null : storage.Load(activeId); if (restoredConversation != null) { session.CurrentConversation = restoredConversation; return new SessionMutationResult(restoredConversation, updatedConversation); } var fallbackConversation = session.LoadOrCreateConversation(normalizedActive, storage, GetFallbackSettings()); session.CurrentConversation = fallbackConversation; return new SessionMutationResult(fallbackConversation, updatedConversation); } private static SettingsService GetFallbackSettings() { return (System.Windows.Application.Current as App)?.SettingsService ?? new SettingsService(); } private static string NormalizeTabName(string? tab) { var normalized = (tab ?? "").Trim(); if (string.IsNullOrEmpty(normalized)) return "Chat"; if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase)) return "Cowork"; var canonical = new string(normalized .Where(char.IsLetterOrDigit) .ToArray()) .ToLowerInvariant(); if (canonical is "cowork" or "coworkcode" or "coworkcodetab") return "Cowork"; if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase) || canonical is "code" or "codetab") return "Code"; return "Chat"; } }