using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; using AxCopilot.Models; namespace AxCopilot.Services.Agent; public class AgentLoopService { private class ParallelState { public int CurrentStep; public int TotalToolCalls; public int MaxIterations; public int ConsecutiveErrors; public int StatsSuccessCount; public int StatsFailCount; public int StatsInputTokens; public int StatsOutputTokens; } private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; private string _conversationId = ""; private bool _docFallbackAttempted; private readonly SemaphoreSlim _pauseSemaphore = new SemaphoreSlim(1, 1); private static readonly HashSet VerificationAllowedTools = new HashSet { "file_read", "directory_list" }; private static readonly HashSet ReadOnlyTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "file_read", "glob", "grep_tool", "folder_map", "document_read", "search_codebase", "code_search", "env_tool", "datetime_tool", "dev_env_detect", "memory", "skill_manager", "json_tool", "regex_tool", "base64_tool", "hash_tool", "image_analyze" }; public ObservableCollection Events { get; } = new ObservableCollection(); public bool IsRunning { get; private set; } public Action? Dispatcher { get; set; } public Func>? AskPermissionCallback { get; set; } public Func, string, Task>? UserAskCallback { get; set; } public string ActiveTab { get; set; } = "Chat"; public bool IsPaused { get; private set; } public Func, Task>? UserDecisionCallback { get; set; } public event Action? EventOccurred; public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings) { _llm = llm; _tools = tools; _settings = settings; } public async Task PauseAsync() { if (!IsPaused && IsRunning) { await _pauseSemaphore.WaitAsync().ConfigureAwait(continueOnCapturedContext: false); IsPaused = true; EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다", null, 0, 0, null, 0L); } } public void Resume() { if (IsPaused) { IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { } EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다", null, 0, 0, null, 0L); } } public async Task RunAsync(List messages, CancellationToken ct = default(CancellationToken)) { if (IsRunning) { throw new InvalidOperationException("에이전트가 이미 실행 중입니다."); } IsRunning = true; _docFallbackAttempted = false; LlmSettings llm = _settings.Settings.Llm; int baseMax = ((llm.MaxAgentIterations > 0) ? llm.MaxAgentIterations : 25); int maxIterations = baseMax; int maxRetry = ((llm.MaxRetryOnError > 0) ? llm.MaxRetryOnError : 3); int iteration = 0; string userQuery = messages.LastOrDefault((ChatMessage m) => m.Role == "user")?.Content ?? ""; int consecutiveErrors = 0; int totalToolCalls = 0; DateTime statsStart = DateTime.Now; int statsSuccessCount = 0; int statsFailCount = 0; int statsInputTokens = 0; int statsOutputTokens = 0; List statsUsedTools = new List(); List planSteps = new List(); int currentStep = 0; bool planExtracted = false; int planExecutionRetry = 0; bool documentPlanCalled = false; int postDocumentPlanRetry = 0; string documentPlanPath = null; string documentPlanTitle = null; string documentPlanScaffold = null; string planMode = llm.PlanMode ?? "off"; AgentContext context = BuildContext(); try { if (planMode == "always") { iteration++; EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중...", null, 0, 0, null, 0L); ChatMessage planInstruction = new ChatMessage { Role = "user", Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. 각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. 계획만 제시하고 실행은 하지 마세요." }; messages.Add(planInstruction); string planText; try { planText = await _llm.SendAsync(messages, ct); } catch (Exception ex) { Exception ex2 = ex; EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex2.Message, null, 0, 0, null, 0L); return "⚠ LLM 오류: " + ex2.Message; } messages.Remove(planInstruction); planSteps = TaskDecomposer.ExtractSteps(planText); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L); if (UserDecisionCallback != null) { string decision = await UserDecisionCallback(planText, new List { "승인", "수정 요청", "취소" }); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L); return "작업이 취소되었습니다."; } 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 < 3; retry++) { try { planText = await _llm.SendAsync(messages, ct); } catch { break; } planSteps = TaskDecomposer.ExtractSteps(planText); if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"수정된 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L); } decision = await UserDecisionCallback(planText, new List { "승인", "수정 요청", "취소" }); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L); 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 }); string planSectionsHint = ((planSteps.Count > 0) ? string.Join(", ", planSteps) : ""); string sectionInstruction = ((!string.IsNullOrEmpty(planSectionsHint)) ? ("document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"" + planSectionsHint + "\"") : ""); messages.Add(new ChatMessage { Role = "user", Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. 텍스트로 설명하지 말고 반드시 도구를 호출하세요." + (string.IsNullOrEmpty(sectionInstruction) ? "" : ("\n" + sectionInstruction)) }); } else if (!string.IsNullOrEmpty(planText)) { messages.Add(new ChatMessage { Role = "assistant", Content = planText }); } } while (iteration < maxIterations && !ct.IsCancellationRequested) { iteration++; await _pauseSemaphore.WaitAsync(ct).ConfigureAwait(continueOnCapturedContext: false); try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { } if (await ContextCondenser.CondenseIfNeededAsync(messages, _llm, llm.MaxContextTokens, ct)) { EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다", null, 0, 0, null, 0L); } EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})", null, 0, 0, null, 0L); if (llm.FreeTierMode && iteration > 1) { int delaySec = ((llm.FreeTierDelaySeconds > 0) ? llm.FreeTierDelaySeconds : 4); EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중...", null, 0, 0, null, 0L); await Task.Delay(delaySec * 1000, ct); } List blocks; try { IReadOnlyCollection activeTools = _tools.GetActiveTools(llm.DisabledTools); blocks = await _llm.SendWithToolsAsync(messages, activeTools, ct); } catch (NotSupportedException) { return await _llm.SendAsync(messages, ct); } catch (ToolCallNotSupportedException ex5) { LogService.Warn("[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: " + ex5.Message); EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…", null, 0, 0, null, 0L); if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "앱에서 직접 문서를 생성합니다...", null, 0, 0, null, 0L); try { List bodyRequest = new List { new ChatMessage { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석의 핵심 항목을 참고하여 풍부한 내용을 채워 완전한 HTML body를 출력하세요. 도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n주제: {documentPlanTitle ?? userQuery}\n\n골격:\n{documentPlanScaffold}" } }; string bodyText = await _llm.SendAsync(bodyRequest, ct); if (!string.IsNullOrEmpty(bodyText)) { IAgentTool htmlTool = _tools.Get("html_create"); if (htmlTool != null) { string fallbackPath = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath)) { string safe = ((userQuery.Length > 40) ? userQuery.Substring(0, 40) : userQuery); char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); foreach (char c in invalidFileNameChars) { safe = safe.Replace(c, '_'); } fallbackPath = safe.Trim() + ".html"; } JsonElement argsJson = 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" } }); ToolResult htmlResult = await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 생성: " + Path.GetFileName(htmlResult.FilePath ?? ""), htmlResult.FilePath, 0, 0, null, 0L); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L); return htmlResult.Output; } } } } catch (Exception ex) { Exception docEx = ex; LogService.Warn("[AgentLoop] document_plan 직접 생성 실패: " + docEx.Message); } } try { return await _llm.SendAsync(messages, ct); } catch (Exception ex6) { EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex6.Message, null, 0, 0, null, 0L); return "⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): " + ex6.Message; } } catch (Exception ex7) { EmitEvent(AgentEventType.Error, "", "LLM 오류: " + ex7.Message, null, 0, 0, null, 0L); return "⚠ LLM 오류: " + ex7.Message; } List textParts = new List(); List toolCalls = new List(); foreach (LlmService.ContentBlock block in blocks) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) { textParts.Add(block.Text); } else if (block.Type == "tool_use") { toolCalls.Add(block); } } string textResponse = string.Join("\n", textParts); if (!planExtracted && !string.IsNullOrEmpty(textResponse)) { planSteps = TaskDecomposer.ExtractSteps(textResponse); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", null, 0, 0, planSteps, 0L); if (planMode == "auto" && toolCalls.Count == 0 && UserDecisionCallback != null) { string decision2 = await UserDecisionCallback(textResponse, new List { "승인", "수정 요청", "취소" }); if (decision2 == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L); return "작업이 취소되었습니다."; } if (decision2 != null && decision2 != "승인") { messages.Add(new ChatMessage { Role = "user", Content = decision2 }); EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중...", null, 0, 0, null, 0L); planExtracted = false; continue; } } } } if (!string.IsNullOrEmpty(textResponse) && toolCalls.Count > 0) { string thinkingSummary = ((textResponse.Length > 150) ? (textResponse.Substring(0, 150) + "…") : textResponse); EmitEvent(AgentEventType.Thinking, "", thinkingSummary, null, 0, 0, null, 0L); } if (toolCalls.Count == 0) { 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...", null, 0, 0, null, 0L); continue; } 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...", null, 0, 0, null, 0L); continue; } if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다...", null, 0, 0, null, 0L); try { List bodyRequest2 = new List { new ChatMessage { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석()의 핵심 항목을 참고하여 풍부한 내용을 채워 완전한 HTML body를 출력하세요. 도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n주제: {documentPlanTitle ?? userQuery}\n\n골격:\n{documentPlanScaffold}" } }; string bodyText2 = await _llm.SendAsync(bodyRequest2, ct); if (!string.IsNullOrEmpty(bodyText2)) { IAgentTool htmlTool2 = _tools.Get("html_create"); if (htmlTool2 != null) { string fallbackPath2 = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath2)) { string safe2 = ((userQuery.Length > 40) ? userQuery.Substring(0, 40) : userQuery); char[] invalidFileNameChars2 = Path.GetInvalidFileNameChars(); foreach (char c2 in invalidFileNameChars2) { safe2 = safe2.Replace(c2, '_'); } fallbackPath2 = safe2.Trim() + ".html"; } JsonElement argsJson2 = JsonSerializer.SerializeToElement(new { path = fallbackPath2, title = (documentPlanTitle ?? userQuery), body = bodyText2, toc = true, numbered = true, mood = "professional", cover = new { title = (documentPlanTitle ?? userQuery), author = "AX Copilot Agent" } }); ToolResult htmlResult2 = await htmlTool2.ExecuteAsync(argsJson2, context, ct); if (htmlResult2.Success) { EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 생성: " + Path.GetFileName(htmlResult2.FilePath ?? ""), htmlResult2.FilePath, 0, 0, null, 0L); textResponse = htmlResult2.Output; } } } } catch (Exception ex) { Exception ex8 = ex; EmitEvent(AgentEventType.Thinking, "", "직접 생성 실패: " + ex8.Message, null, 0, 0, null, 0L); } } if (!_docFallbackAttempted && totalToolCalls == 0 && !string.IsNullOrEmpty(textResponse) && IsDocumentCreationRequest(userQuery)) { _docFallbackAttempted = true; string savedPath = AutoSaveAsHtml(textResponse, userQuery, context); if (savedPath != null) { EmitEvent(AgentEventType.ToolResult, "html_create", "✅ 보고서 파일 자동 생성: " + Path.GetFileName(savedPath), savedPath, 0, 0, null, 0L); textResponse = textResponse + "\n\n\ud83d\udcc4 파일이 저장되었습니다: " + savedPath; } } if (!string.IsNullOrEmpty(textResponse)) { messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); } EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L); return textResponse; } List contentBlocks = new List(); if (!string.IsNullOrEmpty(textResponse)) { contentBlocks.Add(new { type = "text", text = textResponse }); } foreach (LlmService.ContentBlock tc in toolCalls) { contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); } string assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent }); if (llm.EnableParallelTools && toolCalls.Count > 1) { List parallelBatch; List sequentialBatch; (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls); if (parallelBatch.Count > 1) { ParallelState 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; } } foreach (LlmService.ContentBlock call in toolCalls) { if (ct.IsCancellationRequested) { break; } IAgentTool tool = _tools.Get(call.ToolName); if (tool == null) { string errResult = "알 수 없는 도구: " + call.ToolName; EmitEvent(AgentEventType.Error, call.ToolName, errResult, null, 0, 0, null, 0L); messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult)); continue; } if (planSteps.Count > 0) { int newStep = TaskDecomposer.EstimateCurrentStep(toolSummary: FormatToolCallSummary(call), steps: planSteps, toolName: call.ToolName, lastStep: currentStep); if (newStep != currentStep) { currentStep = newStep; EmitEvent(AgentEventType.StepStart, "", planSteps[currentStep], null, currentStep + 1, planSteps.Count, null, 0L); } } if (context.DevMode) { string paramJson = call.ToolInput?.ToString() ?? "{}"; if (paramJson.Length > 500) { paramJson = paramJson.Substring(0, 500) + "..."; } EmitEvent(AgentEventType.Thinking, call.ToolName, "[DEV] 도구 호출: " + call.ToolName + "\n파라미터: " + paramJson, null, 0, 0, null, 0L); } EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call), null, 0, 0, null, 0L); if (context.DevModeStepApproval && UserDecisionCallback != null) { string decision3 = await UserDecisionCallback("[DEV] 도구 '" + call.ToolName + "' 실행을 승인하시겠습니까?\n" + FormatToolCallSummary(call), new List { "승인", "건너뛰기", "중단" }); if (decision3 == "중단") { EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다", null, 0, 0, null, 0L); return "사용자가 개발자 모드에서 실행을 중단했습니다."; } if (decision3 == "건너뛰기") { messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다.")); continue; } } string decisionRequired = CheckDecisionRequired(call, context); if (decisionRequired != null && UserDecisionCallback != null) { string decision4 = await UserDecisionCallback(decisionRequired, new List { "승인", "건너뛰기", "취소" }); if (decision4 == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다", null, 0, 0, null, 0L); return "사용자가 작업을 취소했습니다."; } if (decision4 == "건너뛰기") { messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.")); continue; } } if (llm.EnableToolHooks && llm.AgentHooks.Count > 0) { try { foreach (HookExecutionResult pr in (await AgentHookRunner.RunAsync(llm.AgentHooks, call.ToolName, "pre", call.ToolInput.ToString(), null, success: true, context.WorkFolder, llm.ToolHookTimeoutMs, ct)).Where((HookExecutionResult r) => !r.Success)) { EmitEvent(AgentEventType.Error, call.ToolName, "[Hook:" + pr.HookName + "] " + pr.Output, null, 0, 0, null, 0L); } } catch { } } Stopwatch sw = Stopwatch.StartNew(); ToolResult result; try { JsonElement input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement; result = await tool.ExecuteAsync(input, context, ct); } catch (OperationCanceledException) { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.", null, 0, 0, null, 0L); return "사용자가 작업을 취소했습니다."; } catch (Exception ex10) { result = ToolResult.Fail("도구 실행 오류: " + ex10.Message); } sw.Stop(); if (llm.EnableToolHooks && llm.AgentHooks.Count > 0) { try { foreach (HookExecutionResult pr2 in (await AgentHookRunner.RunAsync(llm.AgentHooks, call.ToolName, "post", call.ToolInput.ToString(), TruncateOutput(result.Output, 2048), result.Success, context.WorkFolder, llm.ToolHookTimeoutMs, ct)).Where((HookExecutionResult r) => !r.Success)) { EmitEvent(AgentEventType.Error, call.ToolName, "[Hook:" + pr2.HookName + "] " + pr2.Output, null, 0, 0, null, 0L); } } catch { } } if (context.DevMode) { EmitEvent(AgentEventType.Thinking, call.ToolName, "[DEV] 결과: " + (result.Success ? "성공" : "실패") + "\n" + TruncateOutput(result.Output, 500), null, 0, 0, null, 0L); } TokenUsage tokenUsage = _llm.LastTokenUsage; EmitEvent(result.Success ? AgentEventType.ToolResult : AgentEventType.Error, call.ToolName, TruncateOutput(result.Output, 200), result.FilePath, 0, 0, null, sw.ElapsedMilliseconds, tokenUsage?.PromptTokens ?? 0, tokenUsage?.CompletionTokens ?? 0, call.ToolInput?.ToString(), iteration); if (result.Success) { statsSuccessCount++; } else { statsFailCount++; } statsInputTokens += tokenUsage?.PromptTokens ?? 0; statsOutputTokens += tokenUsage?.CompletionTokens ?? 0; if (!statsUsedTools.Contains(call.ToolName)) { statsUsedTools.Add(call.ToolName); } if (llm.EnableAuditLog) { AuditLogService.LogToolCall(_conversationId, ActiveTab ?? "", call.ToolName, call.ToolInput.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success); } totalToolCalls++; if (totalToolCalls > 15 && maxIterations < baseMax * 2) { maxIterations = Math.Min(baseMax * 2, 50); } if (call.ToolName == "test_loop" && result.Output.Contains("[AUTO_FIX:")) { int testFixMax = ((llm.MaxTestFixIterations > 0) ? llm.MaxTestFixIterations : 5); int testFixBudget = baseMax + testFixMax * 3; if (maxIterations < testFixBudget) { maxIterations = Math.Min(testFixBudget, 60); } } await Task.Delay(80, ct); if (!result.Success) { consecutiveErrors++; if (consecutiveErrors <= maxRetry) { messages.Add(LlmService.CreateToolResultMessage(result: $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output, 500)}]\nAnalyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. Try a different approach. (Error {consecutiveErrors}/{maxRetry})", toolId: call.ToolId, toolName: call.ToolName)); EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({consecutiveErrors}/{maxRetry})", null, 0, 0, null, 0L); continue; } messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, $"[FAILED after {maxRetry} retries] {TruncateOutput(result.Output, 500)}\n" + "Stop retrying this tool. Explain the error to the user and suggest alternative approaches.")); try { App app = Application.Current as App; AgentMemoryService memSvc = app?.MemoryService; if (memSvc != null && app?.SettingsService?.Settings.Llm.EnableAgentMemory == true) { memSvc.Add("correction", "도구 '" + call.ToolName + "' 반복 실패: " + TruncateOutput(result.Output, 200), "conv:" + _conversationId, context.WorkFolder); } } catch { } continue; } consecutiveErrors = 0; messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000))); if (call.ToolName == "document_plan") { documentPlanCalled = true; string po = result.Output; Match pm = Regex.Match(po, "path:\\s*\"([^\"]+)\""); if (pm.Success) { documentPlanPath = pm.Groups[1].Value; } Match tm = Regex.Match(po, "title:\\s*\"([^\"]+)\""); if (tm.Success) { documentPlanTitle = tm.Groups[1].Value; } int bs = po.IndexOf("--- body 시작 ---", StringComparison.Ordinal); int be = po.IndexOf("--- body 끝 ---", StringComparison.Ordinal); if (bs >= 0 && be > bs) { int num3 = bs + "--- body 시작 ---".Length; documentPlanScaffold = po.Substring(num3, be - num3).Trim(); } } if (call.ToolName == "document_plan" && result.Output.Contains("즉시 실행:")) { string toolHint = (result.Output.Contains("html_create") ? "html_create" : (result.Output.Contains("document_assemble") ? "document_assemble" : (result.Output.Contains("file_write") ? "file_write" : "html_create"))); messages.Add(new ChatMessage { Role = "user", Content = $"document_plan이 완료되었습니다. 위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 {toolHint} 도구를 지금 즉시 호출하세요. 각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요." }); EmitEvent(AgentEventType.Thinking, "", "문서 개요 완성 — " + toolHint + " 호출 중...", null, 0, 0, null, 0L); } if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1) { if ((!(ActiveTab == "Code")) ? (llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName)) : (llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName))) { await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); iteration++; } EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료", null, 0, 0, null, 0L); return result.Output; } if (((!(ActiveTab == "Code")) ? (llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName)) : (llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName))) && result.Success) { await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); iteration++; } } } if (iteration >= maxIterations) { EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)", null, 0, 0, null, 0L); return "⚠ 에이전트가 최대 반복 횟수에 도달했습니다."; } return "(취소됨)"; } finally { IsRunning = false; if (IsPaused) { IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { } } if (totalToolCalls > 0) { long durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds; AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord { Timestamp = statsStart, Tab = (ActiveTab ?? ""), Model = (_settings.Settings.Llm.Model ?? ""), ToolCalls = totalToolCalls, SuccessCount = statsSuccessCount, FailCount = statsFailCount, InputTokens = statsInputTokens, OutputTokens = statsOutputTokens, DurationMs = durationMs, UsedTools = statsUsedTools }); if (llm.ShowTotalCallStats) { int totalTokens = statsInputTokens + statsOutputTokens; double durationSec = (double)durationMs / 1000.0; string toolList = string.Join(", ", statsUsedTools); string summary = $"\ud83d\udcca 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | 토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | 소요 {durationSec:F1}초 | 사용 도구: {toolList}"; EmitEvent(AgentEventType.StepDone, "total_stats", summary, null, 0, 0, null, 0L); } } } } private string? AutoSaveAsHtml(string textContent, string userQuery, AgentContext context) { try { string text = ((userQuery.Length > 60) ? userQuery.Substring(0, 60) : userQuery); string[] array = new string[14] { "작성해줘", "작성해 줘", "만들어줘", "만들어 줘", "써줘", "써 줘", "생성해줘", "생성해 줘", "작성해", "만들어", "생성해", "해줘", "해 줘", "부탁해" }; string text2 = text; string[] array2 = array; foreach (string oldValue in array2) { text2 = text2.Replace(oldValue, "", StringComparison.OrdinalIgnoreCase); } char[] invalidFileNameChars = Path.GetInvalidFileNameChars(); foreach (char oldChar in invalidFileNameChars) { text2 = text2.Replace(oldChar, '_'); } text2 = text2.Trim().TrimEnd('.').Trim(); string path = text2 + ".html"; string text3 = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") { text3 = AgentContext.EnsureTimestampedPath(text3); } string directoryName = Path.GetDirectoryName(text3); if (!string.IsNullOrEmpty(directoryName)) { Directory.CreateDirectory(directoryName); } string css = TemplateService.GetCss("professional"); string value = ConvertTextToHtml(textContent); string contents = $"\n\n\n\n{EscapeHtml(text)}\n\n\n\n
\n

{EscapeHtml(text)}

\n
작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성
\n{value}\n
\n\n"; File.WriteAllText(text3, contents, Encoding.UTF8); LogService.Info("[AgentLoop] 문서 자동 저장 완료: " + text3); return text3; } catch (Exception ex) { LogService.Warn("[AgentLoop] 문서 자동 저장 실패: " + ex.Message); return null; } } private static string ConvertTextToHtml(string text) { StringBuilder stringBuilder = new StringBuilder(); string[] array = text.Split('\n'); bool flag = false; string value = "ul"; string[] array2 = array; foreach (string text2 in array2) { string text3 = text2.TrimEnd(); if (string.IsNullOrWhiteSpace(text3)) { if (flag) { StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder3 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder3.AppendLine(ref handler); flag = false; } } else if (text3.StartsWith("### ")) { StringBuilder stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler; if (flag) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder4 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder4.AppendLine(ref handler); flag = false; } stringBuilder2 = stringBuilder; StringBuilder stringBuilder5 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); string text4 = text3; handler.AppendFormatted(EscapeHtml(text4.Substring(4, text4.Length - 4))); handler.AppendLiteral("

"); stringBuilder5.AppendLine(ref handler); } else if (text3.StartsWith("## ")) { StringBuilder stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler; if (flag) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder6 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder6.AppendLine(ref handler); flag = false; } stringBuilder2 = stringBuilder; StringBuilder stringBuilder7 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); string text4 = text3; handler.AppendFormatted(EscapeHtml(text4.Substring(3, text4.Length - 3))); handler.AppendLiteral("

"); stringBuilder7.AppendLine(ref handler); } else if (text3.StartsWith("# ")) { StringBuilder stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler; if (flag) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder8 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder8.AppendLine(ref handler); flag = false; } stringBuilder2 = stringBuilder; StringBuilder stringBuilder9 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); string text4 = text3; handler.AppendFormatted(EscapeHtml(text4.Substring(2, text4.Length - 2))); handler.AppendLiteral("

"); stringBuilder9.AppendLine(ref handler); } else if (Regex.IsMatch(text3, "^\\d+\\.\\s+\\S")) { string text5 = Regex.Replace(text3, "^\\d+\\.\\s+", ""); if (text5.Length < 80 && !text5.Contains('.') && !text3.StartsWith(" ")) { StringBuilder stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler; if (flag) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder10 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder10.AppendLine(ref handler); flag = false; } stringBuilder2 = stringBuilder; StringBuilder stringBuilder11 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); handler.AppendFormatted(EscapeHtml(text3)); handler.AppendLiteral("

"); stringBuilder11.AppendLine(ref handler); } else { if (!flag) { stringBuilder.AppendLine("
    "); flag = true; value = "ol"; } StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder12 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("
  1. "); handler.AppendFormatted(EscapeHtml(text5)); handler.AppendLiteral("
  2. "); stringBuilder12.AppendLine(ref handler); } } else if (text3.TrimStart().StartsWith("- ") || text3.TrimStart().StartsWith("* ") || text3.TrimStart().StartsWith("• ")) { string text4 = text3.TrimStart(); string text6 = text4.Substring(2, text4.Length - 2).Trim(); if (!flag) { stringBuilder.AppendLine("
      "); flag = true; value = "ul"; } StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder13 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("
    • "); handler.AppendFormatted(EscapeHtml(text6)); handler.AppendLiteral("
    • "); stringBuilder13.AppendLine(ref handler); } else { StringBuilder stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler; if (flag) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder14 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder14.AppendLine(ref handler); flag = false; } stringBuilder2 = stringBuilder; StringBuilder stringBuilder15 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2); handler.AppendLiteral("

      "); handler.AppendFormatted(EscapeHtml(text3)); handler.AppendLiteral("

      "); stringBuilder15.AppendLine(ref handler); } } if (flag) { StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder16 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2); handler.AppendLiteral(""); stringBuilder16.AppendLine(ref handler); } return stringBuilder.ToString(); } private static string EscapeHtml(string text) { return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } private static bool IsDocumentCreationRequest(string query) { if (string.IsNullOrWhiteSpace(query)) { return false; } string[] source = new string[24] { "보고서", "리포트", "report", "문서", "작성해", "써줘", "써 줘", "만들어", "분석서", "제안서", "기획서", "회의록", "매뉴얼", "가이드", "excel", "엑셀", "docx", "word", "html", "pptx", "ppt", "프레젠테이션", "발표자료", "슬라이드" }; string q = query.ToLowerInvariant(); return source.Any((string k) => q.Contains(k, StringComparison.OrdinalIgnoreCase)); } private static bool IsDocumentCreationTool(string toolName) { switch (toolName) { case "file_write": case "docx_create": case "html_create": case "excel_create": case "csv_create": case "script_create": case "pptx_create": return true; default: return false; } } private static bool IsTerminalDocumentTool(string toolName) { switch (toolName) { case "html_create": case "docx_create": case "excel_create": case "pptx_create": case "document_assemble": case "csv_create": return true; default: return false; } } private static bool IsCodeVerificationTarget(string toolName) { switch (toolName) { case "file_write": case "file_edit": case "script_create": case "process": return true; default: return false; } } private async Task RunPostToolVerificationAsync(List messages, string toolName, ToolResult result, AgentContext context, CancellationToken ct) { EmitEvent(AgentEventType.Thinking, "", "\ud83d\udd0d 생성 결과물 검증 중...", null, 0, 0, null, 0L); string filePath = result.FilePath ?? ""; string fileRef = (string.IsNullOrEmpty(filePath) ? "방금 생성한 결과물" : ("파일 '" + filePath + "'")); string checkList = ((ActiveTab == "Code") ? " - 구문 오류가 없는가?\n - 참조하는 클래스/메서드/변수가 존재하는가?\n - 코딩 컨벤션이 일관적인가?\n - 에지 케이스 처리가 누락되지 않았는가?" : " - 사용자 요청에 맞는 내용이 모두 포함되었는가?\n - 구조와 형식이 올바른가?\n - 누락된 섹션이나 불완전한 내용이 없는가?\n - 한국어 맞춤법/표현이 자연스러운가?"); ChatMessage verificationPrompt = new ChatMessage { Role = "user", Content = "[System:Verification] " + fileRef + "을 검증하세요.\n1. file_read 도구로 생성된 파일의 내용을 읽으세요.\n2. 다음 항목을 확인하세요:\n" + checkList + "\n3. 결과를 간단히 보고하세요. 문제가 있으면 구체적으로 무엇이 잘못되었는지 설명하세요.\n⚠\ufe0f 중요: 이 단계에서는 파일을 직접 수정하지 마세요. 보고만 하세요." }; _ = messages.Count; messages.Add(verificationPrompt); List addedMessages = new List { verificationPrompt }; try { IReadOnlyCollection allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools); List readOnlyTools = allTools.Where((IAgentTool t) => VerificationAllowedTools.Contains(t.Name)).ToList(); List verifyBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct); List verifyText = new List(); List verifyToolCalls = new List(); foreach (LlmService.ContentBlock block in verifyBlocks) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) { verifyText.Add(block.Text); } else if (block.Type == "tool_use") { verifyToolCalls.Add(block); } } string verifyResponse = string.Join("\n", verifyText); if (verifyToolCalls.Count > 0) { List contentBlocks = new List(); if (!string.IsNullOrEmpty(verifyResponse)) { contentBlocks.Add(new { type = "text", text = verifyResponse }); } foreach (LlmService.ContentBlock tc in verifyToolCalls) { contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); } string assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); ChatMessage assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent }; messages.Add(assistantMsg); addedMessages.Add(assistantMsg); foreach (LlmService.ContentBlock tc2 in verifyToolCalls) { IAgentTool tool = _tools.Get(tc2.ToolName); if (tool == null) { ChatMessage errMsg = LlmService.CreateToolResultMessage(tc2.ToolId, tc2.ToolName, "검증 단계에서는 읽기 도구만 사용 가능합니다."); messages.Add(errMsg); addedMessages.Add(errMsg); continue; } EmitEvent(AgentEventType.ToolCall, tc2.ToolName, "[검증] " + FormatToolCallSummary(tc2), null, 0, 0, null, 0L); try { JsonElement input = tc2.ToolInput ?? JsonDocument.Parse("{}").RootElement; ChatMessage toolMsg = LlmService.CreateToolResultMessage(result: TruncateOutput((await tool.ExecuteAsync(input, context, ct)).Output, 4000), toolId: tc2.ToolId, toolName: tc2.ToolName); messages.Add(toolMsg); addedMessages.Add(toolMsg); } catch (Exception ex) { Exception ex2 = ex; ChatMessage errMsg2 = LlmService.CreateToolResultMessage(tc2.ToolId, tc2.ToolName, "검증 도구 실행 오류: " + ex2.Message); messages.Add(errMsg2); addedMessages.Add(errMsg2); } } verifyResponse = string.Join("\n", from b in await _llm.SendWithToolsAsync(messages, readOnlyTools, ct) where b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text) select b.Text); } if (!string.IsNullOrEmpty(verifyResponse)) { string summary = ((verifyResponse.Length > 300) ? (verifyResponse.Substring(0, 300) + "…") : verifyResponse); EmitEvent(AgentEventType.Thinking, "", "✅ 검증 결과: " + summary, null, 0, 0, null, 0L); if (verifyResponse.Contains("문제") || verifyResponse.Contains("수정") || verifyResponse.Contains("누락") || verifyResponse.Contains("오류") || verifyResponse.Contains("잘못") || verifyResponse.Contains("부족")) { foreach (ChatMessage msg in addedMessages) { messages.Remove(msg); } messages.Add(new ChatMessage { Role = "user", Content = $"[System] 방금 생성한 {fileRef}에 대한 자동 검증 결과, 다음 문제가 발견되었습니다:\n{verifyResponse}\n\n위 문제를 수정해 주세요." }); return; } } } catch (Exception ex3) { EmitEvent(AgentEventType.Error, "", "검증 LLM 호출 실패: " + ex3.Message, null, 0, 0, null, 0L); } foreach (ChatMessage msg2 in addedMessages) { messages.Remove(msg2); } } private AgentContext BuildContext() { LlmSettings llm = _settings.Settings.Llm; return new AgentContext { WorkFolder = llm.WorkFolder, Permission = llm.FilePermission, BlockedPaths = llm.BlockedPaths, BlockedExtensions = llm.BlockedExtensions, AskPermission = AskPermissionCallback, UserDecision = UserDecisionCallback, UserAskCallback = UserAskCallback, ToolPermissions = (llm.ToolPermissions ?? new Dictionary()), ActiveTab = ActiveTab, DevMode = llm.DevMode, DevModeStepApproval = llm.DevModeStepApproval }; } private void EmitEvent(AgentEventType type, string toolName, string summary, string? filePath = null, int stepCurrent = 0, int stepTotal = 0, List? steps = null, long elapsedMs = 0L, int inputTokens = 0, int outputTokens = 0, string? toolInput = null, int iteration = 0) { string agentLogLevel = _settings.Settings.Llm.AgentLogLevel; bool flag = agentLogLevel == "simple"; bool flag2 = flag; if (flag2) { bool flag3 = (uint)type <= 1u; flag2 = flag3; } if (flag2) { return; } if (agentLogLevel == "simple" && summary.Length > 200) { summary = summary.Substring(0, 200) + "…"; } if (agentLogLevel != "debug") { toolInput = null; } AgentEvent evt = new AgentEvent { Type = type, ToolName = toolName, Summary = summary, FilePath = filePath, Success = (type != AgentEventType.Error), StepCurrent = stepCurrent, StepTotal = stepTotal, Steps = steps, ElapsedMs = elapsedMs, InputTokens = inputTokens, OutputTokens = outputTokens, ToolInput = toolInput, Iteration = iteration }; if (Dispatcher != null) { Dispatcher(delegate { Events.Add(evt); this.EventOccurred?.Invoke(evt); }); } else { Events.Add(evt); this.EventOccurred?.Invoke(evt); } } private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) { string text = ((!(Application.Current is App app)) ? null : app.SettingsService?.Settings.Llm.AgentDecisionLevel) ?? "normal"; string text2 = call.ToolName ?? ""; JsonElement? toolInput = call.ToolInput; if (text2 == "git_tool") { JsonElement value; string text3 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("action", out value)) ? value.GetString() : ""); if (text3 == "commit") { JsonElement value2; string text4 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("args", out value2)) ? value2.GetString() : ""); return "Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: " + text4; } } if (text == "minimal") { if (text2 == "process") { JsonElement value3; string text5 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("command", out value3)) ? value3.GetString() : ""); return "외부 명령을 실행하시겠습니까?\n\n명령: " + text5; } return null; } if (text == "normal" || text == "detailed") { if (text2 == "process") { JsonElement value4; string text6 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("command", out value4)) ? value4.GetString() : ""); return "외부 명령을 실행하시겠습니까?\n\n명령: " + text6; } if (text2 == "file_write") { JsonElement value5; string text7 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value5)) ? value5.GetString() : ""); if (!string.IsNullOrEmpty(text7)) { string path = (Path.IsPathRooted(text7) ? text7 : Path.Combine(context.WorkFolder, text7 ?? "")); if (!File.Exists(path)) { return "새 파일을 생성하시겠습니까?\n\n경로: " + text7; } } } bool flag; switch (text2) { case "excel_create": case "docx_create": case "html_create": case "csv_create": case "script_create": flag = true; break; default: flag = false; break; } if (flag) { JsonElement value6; string text8 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value6)) ? value6.GetString() : ""); return "문서를 생성하시겠습니까?\n\n도구: " + text2 + "\n경로: " + text8; } if ((text2 == "build_run" || text2 == "test_loop") ? true : false) { JsonElement value7; string text9 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("action", out value7)) ? value7.GetString() : ""); return "빌드/테스트를 실행하시겠습니까?\n\n도구: " + text2 + "\n액션: " + text9; } } if (text == "detailed" && ((text2 == "file_write" || text2 == "file_edit") ? true : false)) { JsonElement value8; string text10 = ((toolInput.HasValue && toolInput.GetValueOrDefault().TryGetProperty("file_path", out value8)) ? value8.GetString() : ""); return "파일을 수정하시겠습니까?\n\n경로: " + text10; } return null; } private static string FormatToolCallSummary(LlmService.ContentBlock call) { if (!call.ToolInput.HasValue) { return call.ToolName; } try { JsonElement value = call.ToolInput.Value; if (value.TryGetProperty("path", out var value2)) { return call.ToolName + ": " + value2.GetString(); } if (value.TryGetProperty("command", out var value3)) { return call.ToolName + ": " + value3.GetString(); } if (value.TryGetProperty("pattern", out var value4)) { return call.ToolName + ": " + value4.GetString(); } return call.ToolName; } catch { return call.ToolName; } } private static string TruncateOutput(string output, int maxLength) { if (output.Length <= maxLength) { return output; } return output.Substring(0, maxLength) + "\n... (출력 잘림)"; } private static (List Parallel, List Sequential) ClassifyToolCalls(List calls) { List list = new List(); List list2 = new List(); foreach (LlmService.ContentBlock call in calls) { if (ReadOnlyTools.Contains(call.ToolName ?? "")) { list.Add(call); } else { list2.Add(call); } } if (list.Count <= 1) { list2.InsertRange(0, list); list.Clear(); } return (Parallel: list, Sequential: list2); } private async Task ExecuteToolsInParallelAsync(List calls, List messages, AgentContext context, List planSteps, ParallelState state, int baseMax, int maxRetry, LlmSettings llm, int iteration, CancellationToken ct, List statsUsedTools) { EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...", null, 0, 0, null, 0L); List> tasks = calls.Select(async delegate(LlmService.ContentBlock contentBlock) { IAgentTool tool = _tools.Get(contentBlock.ToolName); if (tool == null) { return (call: contentBlock, ToolResult.Fail("알 수 없는 도구: " + contentBlock.ToolName), 0L); } Stopwatch sw = Stopwatch.StartNew(); try { JsonElement input = contentBlock.ToolInput ?? JsonDocument.Parse("{}").RootElement; ToolResult result2 = await tool.ExecuteAsync(input, context, ct); sw.Stop(); return (call: contentBlock, result2, sw.ElapsedMilliseconds); } catch (Exception ex) { sw.Stop(); return (call: contentBlock, ToolResult.Fail("도구 실행 오류: " + ex.Message), sw.ElapsedMilliseconds); } }).ToList(); (LlmService.ContentBlock call, ToolResult, long)[] array = await Task.WhenAll(tasks); for (int num = 0; num < array.Length; num++) { (LlmService.ContentBlock, ToolResult, long) tuple = array[num]; var (call, result, _) = tuple; EmitEvent(elapsedMs: tuple.Item3, type: result.Success ? AgentEventType.ToolResult : AgentEventType.Error, toolName: call.ToolName, summary: TruncateOutput(result.Output, 200), filePath: result.FilePath, stepCurrent: 0, stepTotal: 0, steps: null, inputTokens: 0, outputTokens: 0, toolInput: null, iteration: iteration); if (result.Success) { state.StatsSuccessCount++; } else { state.StatsFailCount++; } if (!statsUsedTools.Contains(call.ToolName)) { statsUsedTools.Add(call.ToolName); } state.TotalToolCalls++; messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000))); if (!result.Success) { state.ConsecutiveErrors++; if (state.ConsecutiveErrors > maxRetry) { messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, "[FAILED after retries] " + TruncateOutput(result.Output, 500))); } } else { state.ConsecutiveErrors = 0; } if (llm.EnableAuditLog) { AuditLogService.LogToolCall(_conversationId, ActiveTab ?? "", call.ToolName, call.ToolInput?.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success); } } } }