Files

1574 lines
58 KiB
C#

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<string> VerificationAllowedTools = new HashSet<string> { "file_read", "directory_list" };
private static readonly HashSet<string> ReadOnlyTools = new HashSet<string>(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<AgentEvent> Events { get; } = new ObservableCollection<AgentEvent>();
public bool IsRunning { get; private set; }
public Action<Action>? Dispatcher { get; set; }
public Func<string, string, Task<bool>>? AskPermissionCallback { get; set; }
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; set; }
public string ActiveTab { get; set; } = "Chat";
public bool IsPaused { get; private set; }
public Func<string, List<string>, Task<string?>>? UserDecisionCallback { get; set; }
public event Action<AgentEvent>? 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<string> RunAsync(List<ChatMessage> 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<string> statsUsedTools = new List<string>();
List<string> planSteps = new List<string>();
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<string> { "승인", "수정 요청", "취소" });
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<string> { "승인", "수정 요청", "취소" });
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<LlmService.ContentBlock> blocks;
try
{
IReadOnlyCollection<IAgentTool> 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<ChatMessage> bodyRequest = new List<ChatMessage>
{
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<string> textParts = new List<string>();
List<LlmService.ContentBlock> toolCalls = new List<LlmService.ContentBlock>();
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<string> { "승인", "수정 요청", "취소" });
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<ChatMessage> bodyRequest2 = new List<ChatMessage>
{
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<object> contentBlocks = new List<object>();
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<LlmService.ContentBlock> parallelBatch;
List<LlmService.ContentBlock> 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<string> { "승인", "건너뛰기", "중단" });
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<string> { "승인", "건너뛰기", "취소" });
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 = $"<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\">\n<title>{EscapeHtml(text)}</title>\n<style>\n{css}\n.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}\n.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}\n.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}\n.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}\n.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}\n.doc p {{ line-height: 1.8; margin-bottom: 12px; }}\n.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}\n.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}\n.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}\n.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}\n.doc tr:nth-child(even) {{ background: #f8f9fa; }}\n</style>\n</head>\n<body>\n<div class=\"doc\">\n<h1>{EscapeHtml(text)}</h1>\n<div class=\"meta\">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>\n{value}\n</div>\n</body>\n</html>";
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("</");
handler.AppendFormatted(value);
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("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder4.AppendLine(ref handler);
flag = false;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h3>");
string text4 = text3;
handler.AppendFormatted(EscapeHtml(text4.Substring(4, text4.Length - 4)));
handler.AppendLiteral("</h3>");
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("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder6.AppendLine(ref handler);
flag = false;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h2>");
string text4 = text3;
handler.AppendFormatted(EscapeHtml(text4.Substring(3, text4.Length - 3)));
handler.AppendLiteral("</h2>");
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("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder8.AppendLine(ref handler);
flag = false;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h2>");
string text4 = text3;
handler.AppendFormatted(EscapeHtml(text4.Substring(2, text4.Length - 2)));
handler.AppendLiteral("</h2>");
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("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder10.AppendLine(ref handler);
flag = false;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder11 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h2>");
handler.AppendFormatted(EscapeHtml(text3));
handler.AppendLiteral("</h2>");
stringBuilder11.AppendLine(ref handler);
}
else
{
if (!flag)
{
stringBuilder.AppendLine("<ol>");
flag = true;
value = "ol";
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder12 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<li>");
handler.AppendFormatted(EscapeHtml(text5));
handler.AppendLiteral("</li>");
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("<ul>");
flag = true;
value = "ul";
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder13 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<li>");
handler.AppendFormatted(EscapeHtml(text6));
handler.AppendLiteral("</li>");
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("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder14.AppendLine(ref handler);
flag = false;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder15 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("<p>");
handler.AppendFormatted(EscapeHtml(text3));
handler.AppendLiteral("</p>");
stringBuilder15.AppendLine(ref handler);
}
}
if (flag)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder16 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("</");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder16.AppendLine(ref handler);
}
return stringBuilder.ToString();
}
private static string EscapeHtml(string text)
{
return text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
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<ChatMessage> 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<ChatMessage> addedMessages = new List<ChatMessage> { verificationPrompt };
try
{
IReadOnlyCollection<IAgentTool> allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools);
List<IAgentTool> readOnlyTools = allTools.Where((IAgentTool t) => VerificationAllowedTools.Contains(t.Name)).ToList();
List<LlmService.ContentBlock> verifyBlocks = await _llm.SendWithToolsAsync(messages, readOnlyTools, ct);
List<string> verifyText = new List<string>();
List<LlmService.ContentBlock> verifyToolCalls = new List<LlmService.ContentBlock>();
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<object> contentBlocks = new List<object>();
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<string, string>()),
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<string>? 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<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential) ClassifyToolCalls(List<LlmService.ContentBlock> calls)
{
List<LlmService.ContentBlock> list = new List<LlmService.ContentBlock>();
List<LlmService.ContentBlock> list2 = new List<LlmService.ContentBlock>();
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<LlmService.ContentBlock> calls, List<ChatMessage> messages, AgentContext context, List<string> planSteps, ParallelState state, int baseMax, int maxRetry, LlmSettings llm, int iteration, CancellationToken ct, List<string> statsUsedTools)
{
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...", null, 0, 0, null, 0L);
List<Task<(LlmService.ContentBlock call, ToolResult, long)>> 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);
}
}
}
}