using System.Collections.ObjectModel; using System.Diagnostics; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Services.Agent; /// /// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프. /// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다. /// public partial class AgentLoopService { private static App? CurrentApp => System.Windows.Application.Current as App; // ─── Phase 33-A: 매직 넘버 상수화 ─────────────────────────────────── private static class Defaults { // 반복 제어 public const int MaxIterations = 25; public const int MaxRetryOnError = 3; public const int MaxTestFixIterations = 5; public const int MaxPlanRegenerationRetries = 3; public const int MaxToolExecutionRetries = 2; public const int MaxPostDocPlanRetries = 2; public const int FreeTierDelaySeconds = 4; // 컨텍스트 관리 public const int AutoCompactThresholdPercent = 80; public const double ContextCompressionTargetRatio = 0.6; public const int ToolCallThresholdForExpansion = 15; // 텍스트 절단 길이 public const int QueryNameMaxLength = 40; public const int QueryTitleMaxLength = 60; public const int ThinkingTextMaxLength = 150; public const int VerificationSummaryMaxLength = 300; public const int LogSummaryMaxLength = 200; public const int ToolResultTruncateLength = 4000; } private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; private readonly SessionManager? _sessionManager; // Phase 33-D: DI 기반 /// 에이전트 이벤트 스트림 (UI 바인딩용). public ObservableCollection Events { get; } = new(); /// 현재 루프 실행 중 여부. public bool IsRunning { get; private set; } /// 이벤트 발생 시 UI 스레드에서 호출할 디스패처. public Action? Dispatcher { get; set; } /// Ask 모드 권한 확인 콜백. (toolName, filePath) → bool public Func>? AskPermissionCallback { get; set; } /// 에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 응답. public Func, string, Task>? UserAskCallback { get; set; } /// 현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용). public string ActiveTab { get; set; } = "Chat"; /// 현재 대화 ID (감사 로그 기록용). private string _conversationId = ""; /// 문서 생성 폴백 재시도 여부 (루프당 1회만). private bool _docFallbackAttempted; /// 현재 세션 ID (AgentEventLog 기록용). private string _sessionId = ""; /// 현재 세션 이벤트 로그 (비동기 JSONL 기록). private AgentEventLog? _eventLog; /// 위임 에이전트 도구 참조 (서브에이전트 실행기 주입용). private DelegateAgentTool? _delegateAgentTool; /// 일시정지 제어용 세마포어. 1이면 진행, 0이면 대기. private readonly SemaphoreSlim _pauseSemaphore = new(1, 1); /// 현재 일시정지 상태 여부. public bool IsPaused { get; private set; } /// /// 사용자 의사결정 콜백. 계획 제시 후 사용자 승인을 대기합니다. /// (planSummary, options) → 선택된 옵션 텍스트. null이면 승인(계속 진행). /// public Func, Task>? UserDecisionCallback { get; set; } /// 에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용). public event Action? EventOccurred; public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings, SessionManager? sessionManager = null) { _llm = llm; _tools = tools; _settings = settings; _sessionManager = sessionManager; // DelegateAgentTool에 서브에이전트 실행기 주입 _delegateAgentTool = tools.Get("delegate") as DelegateAgentTool; _delegateAgentTool?.SetSubAgentRunner(RunSubAgentAsync); } /// /// 에이전트 루프를 일시정지합니다. /// 다음 반복 시작 시점에서 대기 상태가 됩니다. /// public async Task PauseAsync() { if (IsPaused || !IsRunning) return; // 세마포어를 획득하여 루프가 다음 반복에서 대기하게 함 await _pauseSemaphore.WaitAsync().ConfigureAwait(false); IsPaused = true; EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다"); } /// /// 일시정지된 에이전트 루프를 재개합니다. /// public void Resume() { if (!IsPaused) return; IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { // 이미 릴리즈된 상태 — 무시 } EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다"); } /// /// 에이전트 루프를 실행합니다. /// 사용자 메시지를 LLM에 전달하고, LLM이 도구를 호출하면 실행 후 결과를 다시 LLM에 피드백합니다. /// LLM이 더 이상 도구를 호출하지 않으면 (텍스트만 반환) 루프를 종료합니다. /// /// 대화 메시지 목록 (시스템 프롬프트 포함) /// 취소 토큰 /// 최종 텍스트 응답 public async Task RunAsync(List messages, CancellationToken ct = default) { if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다."); IsRunning = true; _docFallbackAttempted = false; _sessionId = Guid.NewGuid().ToString("N")[..12]; _eventLog = null; // Phase 33-F: ActiveTab 스냅샷 — 루프 중 외부 변경에 의한 레이스 컨디션 방지 var activeTabSnapshot = ActiveTab ?? "Chat"; var llm = _settings.Settings.Llm; if (llm.EventLog?.Enabled ?? true) { _eventLog = new AgentEventLog(_sessionId); _ = _eventLog.AppendAsync(AgentEventLogType.SessionStart, JsonSerializer.Serialize(new { tab = activeTabSnapshot, model = llm.Model ?? "" })); } var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : Defaults.MaxIterations; var maxIterations = baseMax; var maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : Defaults.MaxRetryOnError; var iteration = 0; // 사용자 원본 요청 캡처 (문서 생성 폴백 판단용) var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? ""; var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터 var totalToolCalls = 0; // 복잡도 추정용 // 통계 수집 var statsStart = DateTime.Now; var statsSuccessCount = 0; var statsFailCount = 0; var statsInputTokens = 0; var statsOutputTokens = 0; var statsUsedTools = new List(); // Task Decomposition: 계획 단계 추적 var planSteps = new List(); var currentStep = 0; var planExtracted = false; var planExecutionRetry = 0; // 계획 승인 후 도구 미호출 재시도 카운터 var documentPlanCalled = false; // document_plan 도구 호출 여부 var postDocumentPlanRetry = 0; // document_plan 후 terminal 도구 미호출 재시도 카운터 string? documentPlanPath = null; // document_plan이 제안한 파일명 string? documentPlanTitle = null; // document_plan이 제안한 문서 제목 string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML // 플랜 모드 설정 var planMode = llm.PlanMode ?? "off"; // off | always | auto var context = BuildContext(activeTabSnapshot); try { // ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ── if (planMode == "always") { iteration++; EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중..."); // 계획 생성 전용 시스템 지시를 임시 추가 var planInstruction = new ChatMessage { Role = "user", Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. " + "각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. " + "계획만 제시하고 실행은 하지 마세요." }; messages.Add(planInstruction); // 도구 없이 텍스트만 요청 string planText; try { planText = await _llm.SendAsync(messages, ct); } catch (Exception ex) { EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}"); return $"⚠ LLM 오류: {ex.Message}"; } // 계획 지시 메시지 제거 (실제 실행 시 혼란 방지) messages.Remove(planInstruction); // 계획 추출 planSteps = TaskDecomposer.ExtractSteps(planText); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", steps: planSteps); // 사용자 승인 대기 if (UserDecisionCallback != null) { var decision = await UserDecisionCallback( planText, new List { "승인", "수정 요청", "취소" }); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } else if (decision != null && decision != "승인") { // 수정 요청 — 피드백으로 계획 재생성 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." }); // 재생성 루프 for (int retry = 0; retry < Defaults.MaxPlanRegenerationRetries; retry++) { try { planText = await _llm.SendAsync(messages, ct); } catch (Exception ex) { LogService.Warn($"[AgentLoop] 계획 재생성 실패: {ex.Message}"); break; } planSteps = TaskDecomposer.ExtractSteps(planText); if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"수정된 계획: {planSteps.Count}단계", steps: planSteps); } decision = await UserDecisionCallback( planText, new List { "승인", "수정 요청", "취소" }); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } if (decision == null || decision == "승인") break; // 재수정 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." }); } } } // 승인된 계획을 컨텍스트에 포함하여 실행 유도 // 도구 호출을 명확히 강제하여 텍스트 응답만 반환하는 경우 방지 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); // 1차 계획의 단계들을 document_plan의 sections_hint로 전달하도록 지시 // → BuildSections() 하드코딩 대신 LLM이 잡은 섹션 구조가 문서에 반영됨 var planSectionsHint = planSteps.Count > 0 ? string.Join(", ", planSteps) : ""; var sectionInstruction = !string.IsNullOrEmpty(planSectionsHint) ? $"document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"{planSectionsHint}\"" : ""; messages.Add(new ChatMessage { Role = "user", Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. " + "텍스트로 설명하지 말고 반드시 도구를 호출하세요." + (string.IsNullOrEmpty(sectionInstruction) ? "" : "\n" + sectionInstruction) }); } else { // 계획 추출 실패 — assistant 응답으로 추가하고 일반 모드로 진행 if (!string.IsNullOrEmpty(planText)) messages.Add(new ChatMessage { Role = "assistant", Content = planText }); } } while (iteration < maxIterations && !ct.IsCancellationRequested) { iteration++; // ── 일시정지 체크포인트: Pause() 호출 시 여기서 대기 ── await _pauseSemaphore.WaitAsync(ct).ConfigureAwait(false); try { // 즉시 릴리즈 — 다음 반복에서도 다시 획득할 수 있도록 _pauseSemaphore.Release(); } catch (SemaphoreFullException) { // PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시 } // Phase 33-C: 통합 컨텍스트 관리 — ContextCondenser + AutoCompactMonitor 병합 // 1단계: 기본 압축 (MaxContextTokens 초과 시) // 2단계: 사용량 임계치 초과 시 적극적 압축 (목표 60%) if (llm.MaxContextTokens > 0) { var condensed = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, llm.MaxContextTokens, ct); if (condensed) EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다"); // 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준) if (!condensed && _llm.LastTokenUsage != null) { var threshold = llm.AutoCompactThreshold > 0 ? llm.AutoCompactThreshold : Defaults.AutoCompactThresholdPercent; var monitor = new AutoCompactMonitor(threshold); if (monitor.ShouldCompact(_llm.LastTokenUsage.PromptTokens, llm.MaxContextTokens)) { var usagePct = AutoCompactMonitor.CalculateUsagePercent( _llm.LastTokenUsage.PromptTokens, llm.MaxContextTokens); EmitEvent(AgentEventType.Thinking, "", $"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행"); var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio); var compacted = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, targetTokens, ct); if (compacted) EmitEvent(AgentEventType.Thinking, "", "적극적 컴팩션 완료"); } } } EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"); // 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) if (llm.FreeTierMode && iteration > 1) { var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : Defaults.FreeTierDelaySeconds; EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중..."); await Task.Delay(delaySec * 1000, ct); } // LLM에 도구 정의와 함께 요청 List blocks; try { // Phase 29-B: ToolEnvironmentContext를 사용하여 IConditionalTool 필터링 적용 var toolEnv = new ToolEnvironmentContext { Settings = _settings.Settings, ActiveTab = activeTabSnapshot, WorkFolder = llm.WorkFolder, HasGitRepo = !string.IsNullOrEmpty(llm.WorkFolder) && System.IO.Directory.Exists(System.IO.Path.Combine(llm.WorkFolder, ".git")), AiEnabled = _settings.Settings.AiEnabled, InternalModeEnabled = _settings.Settings.InternalModeEnabled, }; var activeTools = _tools.GetActiveTools(llm.DisabledTools, toolEnv); blocks = await _llm.SendWithToolsAsync(messages, activeTools, ct); } catch (NotSupportedException) { // Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체 var textResp = await _llm.SendAsync(messages, ct); return textResp; } catch (ToolCallNotSupportedException ex) { // 서버가 도구 호출을 400으로 거부 → 도구 없이 일반 응답으로 폴백 LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: {ex.Message}"); EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…"); // document_plan이 완료됐지만 html_create 미실행 → 조기 종료 전에 앱이 직접 생성 if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "앱에서 직접 문서를 생성합니다..."); try { var bodyRequest = new List { new() { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석의 핵심 항목을 참고하여 " + $"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " + $"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" + $"주제: {documentPlanTitle ?? userQuery}\n\n" + $"골격:\n{documentPlanScaffold}" } }; var bodyText = await _llm.SendAsync(bodyRequest, ct); if (!string.IsNullOrEmpty(bodyText)) { var htmlTool = _tools.Get("html_create"); if (htmlTool != null) { var fallbackPath = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath)) { var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery; foreach (var c in System.IO.Path.GetInvalidFileNameChars()) safe = safe.Replace(c, '_'); fallbackPath = $"{safe.Trim()}.html"; } var argsJson = System.Text.Json.JsonSerializer.SerializeToElement(new { path = fallbackPath, title = documentPlanTitle ?? userQuery, body = bodyText, toc = true, numbered = true, mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); var htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}", filePath: htmlResult.FilePath); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return htmlResult.Output; } } } } catch (Exception docEx) { LogService.Warn($"[AgentLoop] document_plan 직접 생성 실패: {docEx.Message}"); } } try { var textResp = await _llm.SendAsync(messages, ct); return textResp; } catch (Exception fallbackEx) { EmitEvent(AgentEventType.Error, "", $"LLM 오류: {fallbackEx.Message}"); return $"⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): {fallbackEx.Message}"; } } catch (Exception ex) { EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}"); return $"⚠ LLM 오류: {ex.Message}"; } // 응답에서 텍스트와 도구 호출 분리 var textParts = new List(); var toolCalls = new List(); foreach (var block in blocks) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) textParts.Add(block.Text); else if (block.Type == "tool_use") toolCalls.Add(block); } // 텍스트 부분 var textResponse = string.Join("\n", textParts); // Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출 if (!planExtracted && !string.IsNullOrEmpty(textResponse)) { planSteps = TaskDecomposer.ExtractSteps(textResponse); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", steps: planSteps); // 플랜 모드 "auto"에서만 승인 대기 // - auto: 계획 감지 시 승인 대기 (단, 도구 호출이 함께 있으면 이미 실행 중이므로 스킵) // - off/always: 승인창 띄우지 않음 (off=자동 진행, always=앞에서 이미 처리됨) var requireApproval = planMode == "auto" && toolCalls.Count == 0; if (requireApproval && UserDecisionCallback != null) { var decision = await UserDecisionCallback( textResponse, new List { "승인", "수정 요청", "취소" }); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } else if (decision != null && decision != "승인") { // 수정 요청 — 사용자 피드백을 메시지에 추가 messages.Add(new ChatMessage { Role = "user", Content = decision }); EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중..."); planExtracted = false; // 재추출 허용 continue; // 루프 재시작 — LLM이 수정된 계획으로 재응답 } // 승인(null) — 그대로 진행 } } } // Thinking UI: 텍스트 응답 중 도구 호출이 있으면 "사고 과정"으로 표시 if (!string.IsNullOrEmpty(textResponse) && toolCalls.Count > 0) { var thinkingSummary = textResponse.Length > Defaults.ThinkingTextMaxLength ? textResponse[..Defaults.ThinkingTextMaxLength] + "…" : textResponse; EmitEvent(AgentEventType.Thinking, "", thinkingSummary); } // 도구 호출이 없으면 루프 종료 — 단, 문서 생성 요청인데 파일이 미생성이면 자동 저장 if (toolCalls.Count == 0) { // 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것 // "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회) if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < 2) { planExecutionRetry++; if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); messages.Add(new ChatMessage { Role = "user", Content = "도구를 호출하지 않았습니다. 계획 1단계를 지금 즉시 도구(tool call)로 실행하세요. " + "설명 없이 도구 호출만 하세요." }); EmitEvent(AgentEventType.Thinking, "", $"도구 미호출 감지 — 실행 재시도 {planExecutionRetry}/2..."); continue; // 루프 재시작 } // document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 재시도 (최대 2회) if (documentPlanCalled && postDocumentPlanRetry < 2) { postDocumentPlanRetry++; if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); messages.Add(new ChatMessage { Role = "user", Content = "html_create 도구를 호출하지 않았습니다. " + "document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " + "html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." }); EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/2..."); continue; // 루프 재시작 } // 재시도도 모두 소진 → 앱이 직접 본문 생성 후 html_create 강제 실행 if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다..."); try { // 도구 없이 LLM에게 HTML body 내용만 요청 var bodyRequest = new List { new() { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석()의 핵심 항목을 참고하여 " + $"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " + $"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" + $"주제: {documentPlanTitle ?? userQuery}\n\n" + $"골격:\n{documentPlanScaffold}" } }; var bodyText = await _llm.SendAsync(bodyRequest, ct); if (!string.IsNullOrEmpty(bodyText)) { var htmlTool = _tools.Get("html_create"); if (htmlTool != null) { // 파일명 정규화 var fallbackPath = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath)) { var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery; foreach (var c in System.IO.Path.GetInvalidFileNameChars()) safe = safe.Replace(c, '_'); fallbackPath = $"{safe.Trim()}.html"; } var argsJson = System.Text.Json.JsonSerializer.SerializeToElement(new { path = fallbackPath, title = documentPlanTitle ?? userQuery, body = bodyText, toc = true, numbered = true, mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); var htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}", filePath: htmlResult.FilePath); textResponse = htmlResult.Output; } } } } catch (Exception ex) { EmitEvent(AgentEventType.Thinking, "", $"직접 생성 실패: {ex.Message}"); } } // LLM이 도구를 한 번도 호출하지 않고 텍스트만 반환 + 문서 생성 요청이면 → 앱이 직접 HTML 파일로 저장 // 주의: 이미 도구가 실행된 경우(totalToolCalls > 0)에는 폴백하지 않음 (중복 파일 방지) if (!_docFallbackAttempted && totalToolCalls == 0 && !string.IsNullOrEmpty(textResponse) && IsDocumentCreationRequest(userQuery)) { _docFallbackAttempted = true; var savedPath = AutoSaveAsHtml(textResponse, userQuery, context); if (savedPath != null) { EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 자동 생성: {System.IO.Path.GetFileName(savedPath)}", filePath: savedPath); textResponse += $"\n\n📄 파일이 저장되었습니다: {savedPath}"; } } if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return textResponse; } // 도구 호출이 있을 때: assistant 메시지에 text + tool_use 블록을 모두 기록 // (Claude API는 assistant 메시지에 tool_use가 포함되어야 tool_result를 매칭함) var contentBlocks = new List(); if (!string.IsNullOrEmpty(textResponse)) contentBlocks.Add(new { type = "text", text = textResponse }); foreach (var tc in toolCalls) contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent }); // 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행) var parallelEnabled = llm.EnableParallelTools && toolCalls.Count > 1; if (parallelEnabled) { var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls); if (parallelBatch.Count > 1) { var pState = new ParallelState { CurrentStep = currentStep, TotalToolCalls = totalToolCalls, MaxIterations = maxIterations, ConsecutiveErrors = consecutiveErrors, StatsSuccessCount = statsSuccessCount, StatsFailCount = statsFailCount, StatsInputTokens = statsInputTokens, StatsOutputTokens = statsOutputTokens, }; await ExecuteToolsInParallelAsync(parallelBatch, messages, context, planSteps, pState, baseMax, maxRetry, llm, iteration, ct, statsUsedTools); currentStep = pState.CurrentStep; totalToolCalls = pState.TotalToolCalls; maxIterations = pState.MaxIterations; consecutiveErrors = pState.ConsecutiveErrors; statsSuccessCount = pState.StatsSuccessCount; statsFailCount = pState.StatsFailCount; statsInputTokens = pState.StatsInputTokens; statsOutputTokens = pState.StatsOutputTokens; } // 병렬 배치 실행 후 순차 배치 실행 toolCalls = sequentialBatch; if (toolCalls.Count == 0) continue; } // Phase 33-B: 도구 실행 루프 — ProcessSingleToolCallAsync로 위임 var execState = new ToolExecutionState { CurrentStep = currentStep, TotalToolCalls = totalToolCalls, MaxIterations = maxIterations, BaseMax = baseMax, MaxRetry = maxRetry, ConsecutiveErrors = consecutiveErrors, StatsSuccessCount = statsSuccessCount, StatsFailCount = statsFailCount, StatsInputTokens = statsInputTokens, StatsOutputTokens = statsOutputTokens, StatsUsedTools = statsUsedTools, PlanSteps = planSteps, DocumentPlanCalled = documentPlanCalled, DocumentPlanPath = documentPlanPath, DocumentPlanTitle = documentPlanTitle, DocumentPlanScaffold = documentPlanScaffold, }; var loopBreak = false; foreach (var call in toolCalls) { if (ct.IsCancellationRequested) break; var (action, returnValue) = await ProcessSingleToolCallAsync( call, messages, context, execState, toolCalls, activeTabSnapshot, iteration, ct); if (action == ToolCallAction.Return) return returnValue ?? ""; if (action == ToolCallAction.Break) { loopBreak = true; break; } } // 상태 동기화 currentStep = execState.CurrentStep; totalToolCalls = execState.TotalToolCalls; maxIterations = execState.MaxIterations; consecutiveErrors = execState.ConsecutiveErrors; statsSuccessCount = execState.StatsSuccessCount; statsFailCount = execState.StatsFailCount; statsInputTokens = execState.StatsInputTokens; statsOutputTokens = execState.StatsOutputTokens; documentPlanCalled = execState.DocumentPlanCalled; documentPlanPath = execState.DocumentPlanPath; documentPlanTitle = execState.DocumentPlanTitle; documentPlanScaffold = execState.DocumentPlanScaffold; if (loopBreak) break; } if (iteration >= maxIterations) { EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)"); return "⚠ 에이전트가 최대 반복 횟수에 도달했습니다."; } return "(취소됨)"; } finally { // 세션 종료 이벤트 기록 if (_eventLog != null) _ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd, JsonSerializer.Serialize(new { totalToolCalls, iteration })); IsRunning = false; // 일시정지 상태 리셋 if (IsPaused) { IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { } } // Phase 33-D: SessionManager — DI 기반 세션 자동 저장 if (_sessionManager != null) { try { var capturedTab = activeTabSnapshot; var session = new AgentSession { Id = _sessionId ?? Guid.NewGuid().ToString("N")[..12], Name = userQuery.Length > Defaults.QueryNameMaxLength ? userQuery[..Defaults.QueryNameMaxLength] + "…" : userQuery, Tab = capturedTab, WorkFolder = llm.WorkFolder ?? "", Model = llm.Model ?? "", Messages = messages, Tags = new List { capturedTab }, CreatedAt = statsStart, UpdatedAt = DateTime.UtcNow, }; await _sessionManager.SaveSessionAsync(session); } catch (Exception ex) { EmitEvent(AgentEventType.Error, "", $"세션 저장 실패: {ex.Message}"); } } // 통계 기록 (도구 호출이 1회 이상인 세션만) if (totalToolCalls > 0) { var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds; AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord { Timestamp = statsStart, Tab = activeTabSnapshot, Model = _settings.Settings.Llm.Model ?? "", ToolCalls = totalToolCalls, SuccessCount = statsSuccessCount, FailCount = statsFailCount, InputTokens = statsInputTokens, OutputTokens = statsOutputTokens, DurationMs = durationMs, UsedTools = statsUsedTools, }); // 전체 호출·토큰 합계 표시 (개발자 모드 설정) if (llm.ShowTotalCallStats) { var totalTokens = statsInputTokens + statsOutputTokens; var durationSec = durationMs / 1000.0; var toolList = string.Join(", ", statsUsedTools); var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " + $"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " + $"소요 {durationSec:F1}초 | 사용 도구: {toolList}"; EmitEvent(AgentEventType.StepDone, "total_stats", summary); } } } } private AgentContext BuildContext(string? tabOverride = null) { var llm = _settings.Settings.Llm; return new AgentContext { Settings = _settings.Settings, WorkFolder = llm.WorkFolder, Permission = llm.FilePermission, BlockedPaths = llm.BlockedPaths, BlockedExtensions = llm.BlockedExtensions, AskPermission = AskPermissionCallback, UserDecision = UserDecisionCallback, UserAskCallback = UserAskCallback, ToolPermissions = llm.ToolPermissions ?? new(), ActiveTab = tabOverride ?? ActiveTab ?? "Chat", DevMode = llm.DevMode, DevModeStepApproval = llm.DevModeStepApproval, }; } }