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, streamingEnabled, 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.CurrentConversation이 전달된 conversation과 다른 경우 (새 대화 시작 등), // session을 통하지 않고 conversation에 직접 추가하여 새 대화가 오염되지 않도록 함. if (session.CurrentConversation == null || string.Equals(session.CurrentConversation.Id, conversation.Id, StringComparison.Ordinal)) { session.AppendMessage(tab, assistant, storage); } else { conversation.Messages.Add(assistant); conversation.UpdatedAt = DateTime.Now; try { storage?.Save(conversation); } catch { } } 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; return BuildFallbackCompletionMessage(conversation, runTab); } 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; return BuildFallbackCompletionMessage(conversation, runTab); } /// /// LLM 응답이 비어있을 때 실행 이벤트에서 의미 있는 완료 메시지를 구성합니다. /// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다. /// private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab) { static bool IsSignificantEventType(string t) => !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase) && !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase) && !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase) && !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase); // 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열) static bool IsInternalStatusSummary(string? summary) { if (string.IsNullOrWhiteSpace(summary)) return false; return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase) || summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase) || summary.Contains("반복 ") && summary.Contains('/') || summary.Contains("[System:"); } var completionLine = runTab switch { "Cowork" => "코워크 작업이 완료되었습니다.", "Code" => "코드 작업이 완료되었습니다.", _ => null, }; // 파일 경로가 있는 이벤트를 최우선으로 — 산출물 파일을 명시적으로 표시 var artifactEvent = conversation.ExecutionEvents? .Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type)) .OrderByDescending(evt => evt.Timestamp) .FirstOrDefault(); if (artifactEvent != null) { var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary) ? $"생성된 파일: {artifactEvent.FilePath}" : $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}"; return completionLine != null ? $"{completionLine}\n\n{fileLine}" : fileLine; } // 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외) var latestSummary = conversation.ExecutionEvents? .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary) && IsSignificantEventType(evt.Type) && !IsInternalStatusSummary(evt.Summary)) .OrderByDescending(evt => evt.Timestamp) .Select(evt => evt.Summary.Trim()) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(latestSummary)) { return completionLine != null ? $"{completionLine}\n\n{latestSummary}" : latestSummary; } return completionLine ?? "(빈 응답)"; } 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"; } }