using System.Collections.ObjectModel; using System.Diagnostics; using System.Collections.Concurrent; using System.IO; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Services.Agent; /// /// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프. /// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다. /// public partial class AgentLoopService { internal const string ApprovedPlanDecisionPrefix = "[PLAN_APPROVED_STEPS]"; public sealed record PermissionPromptPreview( string Kind, string Title, string Summary, string Content, string? PreviousContent = null); private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; private readonly ConcurrentDictionary _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase); /// 에이전트 이벤트 스트림 (UI 바인딩용). public ObservableCollection Events { get; } = new(); /// 현재 루프 실행 중 여부. public bool IsRunning { get; private set; } /// 이벤트 발생 시 UI 스레드에서 호출할 디스패처. public Action? Dispatcher { get; set; } /// Ask 모드 권한 확인 콜백. (toolName, filePath) → bool public Func>? AskPermissionCallback { get; set; } /// 에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 응답. public Func, string, Task>? UserAskCallback { get; set; } /// 현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용). public string ActiveTab { get; set; } = "Chat"; /// 현재 대화 ID (감사 로그 기록용). private string _conversationId = ""; /// 문서 생성 폴백 재시도 여부 (루프당 1회만). private bool _docFallbackAttempted; private string _currentRunId = ""; /// 일시정지 제어용 세마포어. 1이면 진행, 0이면 대기. private readonly SemaphoreSlim _pauseSemaphore = new(1, 1); /// 현재 일시정지 상태 여부. public bool IsPaused { get; private set; } /// /// 사용자 의사결정 콜백. 계획 제시 후 사용자 승인을 대기합니다. /// (planSummary, options) → 선택된 옵션 텍스트. null이면 승인(계속 진행). /// public Func, Task>? UserDecisionCallback { get; set; } /// 에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용). public event Action? EventOccurred; public AgentLoopService(LlmService llm, ToolRegistry tools, SettingsService settings) { _llm = llm; _tools = tools; _settings = settings; } public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview) { preview = null; var key = BuildPermissionPreviewKey(toolName, target); if (string.IsNullOrWhiteSpace(key)) return false; if (_pendingPermissionPreviews.TryGetValue(key, out var value)) { preview = value; return true; } return false; } /// /// 에이전트 루프를 일시정지합니다. /// 다음 반복 시작 시점에서 대기 상태가 됩니다. /// public async Task PauseAsync() { if (IsPaused || !IsRunning) return; // 세마포어를 획득하여 루프가 다음 반복에서 대기하게 함 await _pauseSemaphore.WaitAsync().ConfigureAwait(false); IsPaused = true; EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다"); } /// /// 일시정지된 에이전트 루프를 재개합니다. /// public void Resume() { if (!IsPaused) return; IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { // 이미 릴리즈된 상태 — 무시 } EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다"); } /// /// 에이전트 루프를 실행합니다. /// 사용자 메시지를 LLM에 전달하고, LLM이 도구를 호출하면 실행 후 결과를 다시 LLM에 피드백합니다. /// LLM이 더 이상 도구를 호출하지 않으면 (텍스트만 반환) 루프를 종료합니다. /// /// 대화 메시지 목록 (시스템 프롬프트 포함) /// 취소 토큰 /// 최종 텍스트 응답 public async Task RunAsync(List messages, CancellationToken ct = default) { if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다."); IsRunning = true; _currentRunId = Guid.NewGuid().ToString("N"); _docFallbackAttempted = false; var llm = _settings.Settings.Llm; var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : 25; var maxIterations = baseMax; // 동적 조정 가능 var maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : 3; var iteration = 0; // 사용자 원본 요청 캡처 (문서 생성 폴백 판단용) var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? ""; var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터 var totalToolCalls = 0; // 복잡도 추정용 string? lastFailedToolSignature = null; var repeatedFailedToolSignatureCount = 0; string? lastAnyToolSignature = null; var repeatedAnyToolSignatureCount = 0; var consecutiveReadOnlySuccessTools = 0; var consecutiveNonMutatingSuccessTools = 0; string? lastUnknownToolName = null; var repeatedUnknownToolCount = 0; string? lastDisallowedToolName = null; var repeatedDisallowedToolCount = 0; // 통계 수집 var statsStart = DateTime.Now; var statsSuccessCount = 0; var statsFailCount = 0; var statsInputTokens = 0; var statsOutputTokens = 0; var statsRepeatedFailureBlocks = 0; var statsRecoveredAfterFailure = 0; var recoveryPendingAfterFailure = false; var statsUsedTools = new List(); // Task Decomposition: 계획 단계 추적 var planSteps = new List(); var currentStep = 0; var planExtracted = false; var planExecutionRetry = 0; // 계획 승인 후 도구 미호출 재시도 카운터 var documentPlanCalled = false; // document_plan 도구 호출 여부 var postDocumentPlanRetry = 0; // document_plan 후 terminal 도구 미호출 재시도 카운터 string? documentPlanPath = null; // document_plan이 제안한 파일명 string? documentPlanTitle = null; // document_plan이 제안한 문서 제목 string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적 var consecutiveNoToolResponses = 0; var noToolResponseThreshold = GetNoToolCallResponseThreshold(); var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(); var planExecutionRetryMax = GetPlanExecutionRetryMax(); var failedToolHistogram = new Dictionary(StringComparer.OrdinalIgnoreCase); var runState = new RunState(); var requireHighImpactCodeVerification = false; string? lastModifiedCodeFilePath = null; var taskType = ClassifyTaskType(userQuery, ActiveTab); var taskPolicy = TaskTypePolicy.FromTaskType(taskType); maxRetry = ComputeAdaptiveMaxRetry(maxRetry, taskPolicy.TaskType); var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType); maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType); // 플랜 모드 설정 var planMode = llm.PlanMode ?? "off"; // off | always | auto var context = BuildContext(); InjectTaskTypeGuidance(messages, taskPolicy); InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy); var runtimeOverrides = ResolveSkillRuntimeOverrides(messages); var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides); var runtimeOverrideApplied = false; string? lastUserPromptHookFingerprint = null; var enforceForkExecution = runtimeOverrides?.RequireForkExecution == true && llm.EnableForkSkillDelegationEnforcement; var forkEnforcementAttempts = 0; var forkDelegationObserved = false; if (runtimeOverrides != null) { _llm.PushInferenceOverride( runtimeOverrides.Service, runtimeOverrides.Model, runtimeOverrides.Temperature, runtimeOverrides.ReasoningEffort); runtimeOverrideApplied = true; EmitEvent( AgentEventType.Thinking, "", $"[SkillRuntime] model={runtimeOverrides.Model ?? "(기본)"} service={runtimeOverrides.Service ?? "(기본)"} effort={runtimeOverrides.ReasoningEffort ?? "(기본)"}" + (runtimeOverrides.AllowedToolNames.Count > 0 ? $" allowed_tools={runtimeOverrides.AllowedToolNames.Count}개" : "") + ((runtimeOverrides.HookNames.Count > 0 || runtimeOverrides.HookFilters.Count > 0) ? $" hooks={runtimeHooks.Count}개" : "")); } async Task RunRuntimeHooksAsync( string hookToolName, string timing, string? inputJson = null, string? output = null, bool success = true) { if (!llm.EnableToolHooks || runtimeHooks.Count == 0) return; try { var selected = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, hookToolName, timing); var results = await AgentHookRunner.RunAsync( selected, hookToolName, timing, toolInput: inputJson, toolOutput: output, success: success, workFolder: context.WorkFolder, timeoutMs: llm.ToolHookTimeoutMs, ct: ct); foreach (var pr in results) { EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); ApplyHookAdditionalContext(messages, pr); ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); } } catch { // lifecycle hook failure is non-blocking } } try { EmitEvent(AgentEventType.SessionStart, "", "에이전트 세션 시작"); await RunRuntimeHooksAsync( "__session_start__", "pre", JsonSerializer.Serialize(new { runId = _currentRunId, tab = ActiveTab, workFolder = context.WorkFolder })); // ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ── if (planMode == "always") { iteration++; EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중..."); // 계획 생성 전용 시스템 지시를 임시 추가 var planInstruction = new ChatMessage { Role = "user", Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. " + "각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. " + "계획만 제시하고 실행은 하지 마세요." }; messages.Add(planInstruction); // 도구 없이 텍스트만 요청 string planText; try { planText = await _llm.SendAsync(messages, ct); } catch (Exception ex) { EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}"); return $"⚠ LLM 오류: {ex.Message}"; } // 계획 지시 메시지 제거 (실제 실행 시 혼란 방지) messages.Remove(planInstruction); // 계획 추출 planSteps = TaskDecomposer.ExtractSteps(planText); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", steps: planSteps); // 사용자 승인 대기 if (UserDecisionCallback != null) { EmitEvent( AgentEventType.Decision, "", $"계획 확인 대기 · {planSteps.Count}단계", steps: planSteps); var decision = await UserDecisionCallback( planText, new List { "승인", "수정 요청", "취소" }); EmitPlanDecisionResultEvent(decision, planSteps); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps)) { planText = approvedPlanText; planSteps = approvedPlanSteps; } else if (decision != null && decision != "승인") { // 수정 요청 — 피드백으로 계획 재생성 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." }); // 재생성 루프 (최대 3회) 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}단계", steps: planSteps); } EmitEvent( AgentEventType.Decision, "", $"수정 계획 확인 대기 · {planSteps.Count}단계", steps: planSteps); decision = await UserDecisionCallback( planText, new List { "승인", "수정 요청", "취소" }); EmitPlanDecisionResultEvent(decision, planSteps); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } if (TryParseApprovedPlanDecision(decision, out var revisedPlanText, out var revisedPlanSteps)) { planText = revisedPlanText; planSteps = revisedPlanSteps; break; } if (decision == null || decision == "승인") break; // 재수정 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); messages.Add(new ChatMessage { Role = "user", Content = decision + "\n위 피드백을 반영하여 실행 계획을 다시 작성하세요." }); } } } // 승인된 계획을 컨텍스트에 포함하여 실행 유도 // 도구 호출을 명확히 강제하여 텍스트 응답만 반환하는 경우 방지 messages.Add(new ChatMessage { Role = "assistant", Content = planText }); // 1차 계획의 단계들을 document_plan의 sections_hint로 전달하도록 지시 // → BuildSections() 하드코딩 대신 LLM이 잡은 섹션 구조가 문서에 반영됨 var planSectionsHint = planSteps.Count > 0 ? string.Join(", ", planSteps) : ""; var sectionInstruction = !string.IsNullOrEmpty(planSectionsHint) ? $"document_plan 도구를 호출할 때 sections_hint 파라미터에 위 계획의 섹션/단계를 그대로 넣으세요: \"{planSectionsHint}\"" : ""; messages.Add(new ChatMessage { Role = "user", Content = "계획이 승인되었습니다. 지금 즉시 1단계부터 도구(tool)를 호출하여 실행을 시작하세요. " + "텍스트로 설명하지 말고 반드시 도구를 호출하세요." + (string.IsNullOrEmpty(sectionInstruction) ? "" : "\n" + sectionInstruction) }); } else { // 계획 추출 실패 — assistant 응답으로 추가하고 일반 모드로 진행 if (!string.IsNullOrEmpty(planText)) messages.Add(new ChatMessage { Role = "assistant", Content = planText }); } } while (iteration < maxIterations && !ct.IsCancellationRequested) { iteration++; // ── 일시정지 체크포인트: Pause() 호출 시 여기서 대기 ── await _pauseSemaphore.WaitAsync(ct).ConfigureAwait(false); try { // 즉시 릴리즈 — 다음 반복에서도 다시 획득할 수 있도록 _pauseSemaphore.Release(); } catch (SemaphoreFullException) { // PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시 } // Context Condenser: 토큰 초과 시 이전 대화 자동 압축 // 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비) { var condensed = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, llm.MaxContextTokens, llm.EnableProactiveContextCompact, llm.ContextCompactTriggerPercent, false, ct); if (condensed) EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다"); } EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})"); // 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) if (llm.FreeTierMode && iteration > 1) { var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : 4; EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중..."); await Task.Delay(delaySec * 1000, ct); } var latestUserPrompt = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content; if (!string.IsNullOrWhiteSpace(latestUserPrompt)) { var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}"; if (!string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal)) { lastUserPromptHookFingerprint = fingerprint; EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출"); await RunRuntimeHooksAsync( "__user_prompt_submit__", "pre", JsonSerializer.Serialize(new { prompt = TruncateOutput(latestUserPrompt, 4000), runId = _currentRunId, tab = ActiveTab })); } } // LLM에 도구 정의와 함께 요청 List blocks; try { var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides); if (activeTools.Count == 0) { EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다."); return "⚠ 현재 스킬 정책에서 허용된 도구가 없어 작업을 진행할 수 없습니다. allowed-tools 설정을 확인하세요."; } blocks = await SendWithToolsWithRecoveryAsync( messages, activeTools, ct, $"메인 루프 {iteration}", runState); runState.ContextRecoveryAttempts = 0; runState.TransientLlmErrorRetries = 0; } catch (NotSupportedException) { // Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체 var textResp = await _llm.SendAsync(messages, ct); return textResp; } catch (ToolCallNotSupportedException ex) { // 서버가 도구 호출을 400으로 거부 → 도구 없이 일반 응답으로 폴백 LogService.Warn($"[AgentLoop] 도구 호출 거부됨, 일반 응답으로 폴백: {ex.Message}"); EmitEvent(AgentEventType.Thinking, "", "도구 호출이 거부되어 일반 응답으로 전환합니다…"); // document_plan이 완료됐지만 html_create 미실행 → 조기 종료 전에 앱이 직접 생성 if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "앱에서 직접 문서를 생성합니다..."); try { var bodyRequest = new List { new() { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석의 핵심 항목을 참고하여 " + $"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " + $"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" + $"주제: {documentPlanTitle ?? userQuery}\n\n" + $"골격:\n{documentPlanScaffold}" } }; var bodyText = await _llm.SendAsync(bodyRequest, ct); if (!string.IsNullOrEmpty(bodyText)) { var htmlTool = _tools.Get("html_create"); if (htmlTool != null) { var fallbackPath = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath)) { var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery; foreach (var c in System.IO.Path.GetInvalidFileNameChars()) safe = safe.Replace(c, '_'); fallbackPath = $"{safe.Trim()}.html"; } var argsJson = System.Text.Json.JsonSerializer.SerializeToElement(new { path = fallbackPath, title = documentPlanTitle ?? userQuery, body = bodyText, toc = true, numbered = true, mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages) ?? await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}", filePath: htmlResult.FilePath); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return htmlResult.Output; } } } } catch (Exception docEx) { LogService.Warn($"[AgentLoop] document_plan 직접 생성 실패: {docEx.Message}"); } } try { var textResp = await _llm.SendAsync(messages, ct); return textResp; } catch (Exception fallbackEx) { EmitEvent(AgentEventType.Error, "", $"LLM 오류: {fallbackEx.Message}"); return $"⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): {fallbackEx.Message}"; } } catch (Exception ex) { if (TryHandleContextOverflowTransition(ex, messages, runState)) continue; if (await TryHandleTransientLlmErrorTransitionAsync(ex, runState, ct)) continue; EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}"); return $"⚠ LLM 오류: {ex.Message}"; } // 응답에서 텍스트와 도구 호출 분리 var textParts = new List(); var toolCalls = new List(); foreach (var block in blocks) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) textParts.Add(block.Text); else if (block.Type == "tool_use") toolCalls.Add(block); } // 텍스트 부분 var textResponse = string.Join("\n", textParts); consecutiveNoToolResponses = toolCalls.Count == 0 ? consecutiveNoToolResponses + 1 : 0; // Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출 if (!planExtracted && !string.IsNullOrEmpty(textResponse)) { planSteps = TaskDecomposer.ExtractSteps(textResponse); planExtracted = true; if (planSteps.Count > 0) { EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계", steps: planSteps); // 플랜 모드 "auto"에서만 승인 대기 // - auto: 계획 감지 시 승인 대기 (단, 도구 호출이 함께 있으면 이미 실행 중이므로 스킵) // - off/always: 승인창 띄우지 않음 (off=자동 진행, always=앞에서 이미 처리됨) var requireApproval = planMode == "auto" && toolCalls.Count == 0; if (requireApproval && UserDecisionCallback != null) { EmitEvent( AgentEventType.Decision, "", $"계획 확인 대기 · {planSteps.Count}단계", steps: planSteps); var decision = await UserDecisionCallback( textResponse, new List { "승인", "수정 요청", "취소" }); EmitPlanDecisionResultEvent(decision, planSteps); if (decision == "취소") { EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다"); return "작업이 취소되었습니다."; } else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps)) { textResponse = approvedPlanText; planSteps = approvedPlanSteps; } else if (decision != null && decision != "승인") { // 수정 요청 — 사용자 피드백을 메시지에 추가 messages.Add(new ChatMessage { Role = "user", Content = decision }); EmitEvent(AgentEventType.Thinking, "", "사용자 피드백 반영 중..."); planExtracted = false; // 재추출 허용 continue; // 루프 재시작 — LLM이 수정된 계획으로 재응답 } // 승인(null) — 그대로 진행 } } } // Thinking UI: 텍스트 응답 중 도구 호출이 있으면 "사고 과정"으로 표시 if (!string.IsNullOrEmpty(textResponse) && toolCalls.Count > 0) { var thinkingSummary = textResponse.Length > 150 ? textResponse[..150] + "…" : textResponse; EmitEvent(AgentEventType.Thinking, "", thinkingSummary); } // 도구 호출이 없으면 루프 종료 — 단, 문서 생성 요청인데 파일이 미생성이면 자동 저장 if (toolCalls.Count == 0) { if (totalToolCalls == 0 && consecutiveNoToolResponses >= noToolResponseThreshold && runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries) { runState.NoToolCallLoopRetry++; if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); var activeToolPreview = string.Join(", ", GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides) .Select(t => t.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(10)); messages.Add(new ChatMessage { Role = "user", Content = "[System:ToolCallRequired] 현재 응답이 연속으로 도구 호출 없이 종료되고 있습니다. " + "설명만 하지 말고 즉시 도구를 최소 1회 이상 호출해 실행을 진행하세요. " + $"사용 가능 도구 예시: {activeToolPreview}" }); EmitEvent( AgentEventType.Thinking, "", $"도구 미호출 루프 감지 — 강제 실행 유도 {runState.NoToolCallLoopRetry}/{noToolRecoveryMaxRetries} (임계 {noToolResponseThreshold})"); continue; } if (TryHandleWithheldResponseTransition(textResponse, messages, runState)) continue; // 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것 // "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회) if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < planExecutionRetryMax) { 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}/{planExecutionRetryMax}..."); continue; // 루프 재시작 } // document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 재시도 (최대 2회) if (documentPlanCalled && postDocumentPlanRetry < 2) { postDocumentPlanRetry++; if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); messages.Add(new ChatMessage { Role = "user", Content = "html_create 도구를 호출하지 않았습니다. " + "document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " + "html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." }); EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/2..."); continue; // 루프 재시작 } // 재시도도 모두 소진 → 앱이 직접 본문 생성 후 html_create 강제 실행 if (documentPlanCalled && !string.IsNullOrEmpty(documentPlanScaffold) && !_docFallbackAttempted) { _docFallbackAttempted = true; EmitEvent(AgentEventType.Thinking, "", "LLM이 html_create를 호출하지 않아 앱에서 직접 문서를 생성합니다..."); try { // 도구 없이 LLM에게 HTML body 내용만 요청 var bodyRequest = new List { new() { Role = "user", Content = $"아래 HTML 골격의 각 h2 섹션에 주석()의 핵심 항목을 참고하여 " + $"풍부한 내용을 채워 완전한 HTML body를 출력하세요. " + $"도구를 호출하지 말고 HTML 코드만 출력하세요.\n\n" + $"주제: {documentPlanTitle ?? userQuery}\n\n" + $"골격:\n{documentPlanScaffold}" } }; var bodyText = await _llm.SendAsync(bodyRequest, ct); if (!string.IsNullOrEmpty(bodyText)) { var htmlTool = _tools.Get("html_create"); if (htmlTool != null) { // 파일명 정규화 var fallbackPath = documentPlanPath; if (string.IsNullOrEmpty(fallbackPath)) { var safe = userQuery.Length > 40 ? userQuery[..40] : userQuery; foreach (var c in System.IO.Path.GetInvalidFileNameChars()) safe = safe.Replace(c, '_'); fallbackPath = $"{safe.Trim()}.html"; } var argsJson = System.Text.Json.JsonSerializer.SerializeToElement(new { path = fallbackPath, title = documentPlanTitle ?? userQuery, body = bodyText, toc = true, numbered = true, mood = "professional", cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" } }); var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages) ?? await htmlTool.ExecuteAsync(argsJson, context, ct); if (htmlResult.Success) { if (!string.IsNullOrWhiteSpace(htmlResult.FilePath)) lastArtifactFilePath = htmlResult.FilePath; EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 생성: {System.IO.Path.GetFileName(htmlResult.FilePath ?? "")}", filePath: htmlResult.FilePath); textResponse = htmlResult.Output; } } } } catch (Exception ex) { EmitEvent(AgentEventType.Thinking, "", $"직접 생성 실패: {ex.Message}"); } } // LLM이 도구를 한 번도 호출하지 않고 텍스트만 반환 + 문서 생성 요청이면 → 앱이 직접 HTML 파일로 저장 // 주의: 이미 도구가 실행된 경우(totalToolCalls > 0)에는 폴백하지 않음 (중복 파일 방지) if (!_docFallbackAttempted && totalToolCalls == 0 && !string.IsNullOrEmpty(textResponse) && IsDocumentCreationRequest(userQuery)) { _docFallbackAttempted = true; var savedPath = AutoSaveAsHtml(textResponse, userQuery, context); if (savedPath != null) { lastArtifactFilePath = savedPath; EmitEvent(AgentEventType.ToolResult, "html_create", $"✅ 보고서 파일 자동 생성: {System.IO.Path.GetFileName(savedPath)}", filePath: savedPath); textResponse += $"\n\n📄 파일이 저장되었습니다: {savedPath}"; } } if (TryApplyDocumentArtifactGateTransition( messages, textResponse, taskPolicy, lastArtifactFilePath, documentPlanPath, runState)) continue; if (TryApplyDocumentVerificationGateTransition( messages, textResponse, taskPolicy, lastArtifactFilePath, runState)) continue; if (TryApplyCodeDiffEvidenceGateTransition( messages, textResponse, runState)) continue; if (TryApplyRecentExecutionEvidenceGateTransition( messages, textResponse, taskPolicy, runState)) continue; if (TryApplyExecutionSuccessGateTransition( messages, textResponse, taskPolicy, runState)) continue; if (TryApplyCodeCompletionGateTransition( messages, textResponse, taskPolicy, requireHighImpactCodeVerification, totalToolCalls, runState)) continue; if (TryApplyTerminalEvidenceGateTransition( messages, textResponse, taskPolicy, userQuery, totalToolCalls, lastArtifactFilePath, runState)) continue; if (!string.IsNullOrEmpty(textResponse)) messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return textResponse; } // 도구 호출이 있을 때: assistant 메시지에 text + tool_use 블록을 모두 기록 // (Claude API는 assistant 메시지에 tool_use가 포함되어야 tool_result를 매칭함) var contentBlocks = new List(); if (!string.IsNullOrEmpty(textResponse)) contentBlocks.Add(new { type = "text", text = textResponse }); foreach (var tc in toolCalls) contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent }); // 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행) var parallelPlan = CreateParallelExecutionPlan(llm.EnableParallelTools, toolCalls); if (parallelPlan.ShouldRun) { if (parallelPlan.ParallelBatch.Count > 1) { var pState = new ParallelState { CurrentStep = currentStep, TotalToolCalls = totalToolCalls, ConsecutiveReadOnlySuccessTools = consecutiveReadOnlySuccessTools, ConsecutiveNonMutatingSuccessTools = consecutiveNonMutatingSuccessTools, MaxIterations = maxIterations, ConsecutiveErrors = consecutiveErrors, StatsSuccessCount = statsSuccessCount, StatsFailCount = statsFailCount, StatsInputTokens = statsInputTokens, StatsOutputTokens = statsOutputTokens, StatsRepeatedFailureBlocks = statsRepeatedFailureBlocks, StatsRecoveredAfterFailure = statsRecoveredAfterFailure, RecoveryPendingAfterFailure = recoveryPendingAfterFailure, LastFailedToolSignature = lastFailedToolSignature, RepeatedFailedToolSignatureCount = repeatedFailedToolSignatureCount, }; await ExecuteToolsInParallelAsync(parallelPlan.ParallelBatch, messages, context, planSteps, pState, baseMax, maxRetry, llm, iteration, ct, statsUsedTools); currentStep = pState.CurrentStep; totalToolCalls = pState.TotalToolCalls; consecutiveReadOnlySuccessTools = pState.ConsecutiveReadOnlySuccessTools; consecutiveNonMutatingSuccessTools = pState.ConsecutiveNonMutatingSuccessTools; maxIterations = pState.MaxIterations; consecutiveErrors = pState.ConsecutiveErrors; statsSuccessCount = pState.StatsSuccessCount; statsFailCount = pState.StatsFailCount; statsInputTokens = pState.StatsInputTokens; statsOutputTokens = pState.StatsOutputTokens; statsRepeatedFailureBlocks = pState.StatsRepeatedFailureBlocks; statsRecoveredAfterFailure = pState.StatsRecoveredAfterFailure; recoveryPendingAfterFailure = pState.RecoveryPendingAfterFailure; lastFailedToolSignature = pState.LastFailedToolSignature; repeatedFailedToolSignatureCount = pState.RepeatedFailedToolSignatureCount; if (TryHandleReadOnlyStagnationTransition( consecutiveReadOnlySuccessTools, messages, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy)) { consecutiveReadOnlySuccessTools = 0; } if (TryHandleNoProgressExecutionTransition( consecutiveNonMutatingSuccessTools, messages, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy, documentPlanPath, runState)) { consecutiveNonMutatingSuccessTools = 0; continue; } if (ShouldAbortNoProgressExecution( consecutiveNonMutatingSuccessTools, runState.NoProgressRecoveryRetry)) { EmitEvent(AgentEventType.Error, "", "비진행 상태가 장시간 지속되어 작업을 중단합니다."); return BuildNoProgressAbortResponse( taskPolicy, consecutiveNonMutatingSuccessTools, runState.NoProgressRecoveryRetry, lastArtifactFilePath, statsUsedTools); } } // 병렬 배치 실행 후 순차 배치 실행 toolCalls = parallelPlan.SequentialBatch; if (toolCalls.Count == 0) continue; } foreach (var call in toolCalls) { if (ct.IsCancellationRequested) break; var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides) .Select(t => t.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames); var globallyRegisteredTool = _tools.Get(resolvedToolName); if (globallyRegisteredTool != null && !activeToolNames.Any(name => string.Equals(name, globallyRegisteredTool.Name, StringComparison.OrdinalIgnoreCase))) { var errResult = $"현재 런타임 정책에서 허용되지 않은 도구입니다: {call.ToolName}"; if (string.Equals(lastDisallowedToolName, call.ToolName, StringComparison.OrdinalIgnoreCase)) repeatedDisallowedToolCount++; else { lastDisallowedToolName = call.ToolName; repeatedDisallowedToolCount = 1; } EmitEvent(AgentEventType.Error, call.ToolName, errResult); messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult)); messages.Add(new ChatMessage { Role = "user", Content = BuildDisallowedToolRecoveryPrompt( call.ToolName, runtimeOverrides?.AllowedToolNames, activeToolNames) }); if (repeatedDisallowedToolCount >= 3) { EmitEvent(AgentEventType.Error, call.ToolName, "허용되지 않은 도구 호출이 반복되어 작업을 중단합니다."); return BuildDisallowedToolLoopAbortResponse( call.ToolName, repeatedDisallowedToolCount, activeToolNames); } continue; } var tool = _tools.Get(resolvedToolName); if (tool == null) { var errResult = $"알 수 없는 도구: {call.ToolName}"; if (string.Equals(lastUnknownToolName, call.ToolName, StringComparison.OrdinalIgnoreCase)) repeatedUnknownToolCount++; else { lastUnknownToolName = call.ToolName; repeatedUnknownToolCount = 1; } EmitEvent(AgentEventType.Error, call.ToolName, errResult); messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, errResult)); messages.Add(new ChatMessage { Role = "user", Content = BuildUnknownToolRecoveryPrompt(call.ToolName, activeToolNames) }); if (repeatedUnknownToolCount >= 3) { EmitEvent(AgentEventType.Error, call.ToolName, "동일한 알 수 없는 도구 호출이 반복되어 작업을 중단합니다."); return BuildUnknownToolLoopAbortResponse( call.ToolName, repeatedUnknownToolCount, activeToolNames); } continue; } lastUnknownToolName = null; repeatedUnknownToolCount = 0; lastDisallowedToolName = null; repeatedDisallowedToolCount = 0; var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase) ? call : new LlmService.ContentBlock { Type = call.Type, Text = call.Text, ToolName = resolvedToolName, ToolId = call.ToolId, ToolInput = call.ToolInput, }; if (!string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)) { EmitEvent( AgentEventType.Thinking, resolvedToolName, $"도구명 정규화 적용: '{call.ToolName}' → '{resolvedToolName}'"); } var toolCallSignature = BuildToolCallSignature(effectiveCall); var previewRepeatedToolCount = string.Equals( lastAnyToolSignature, toolCallSignature, StringComparison.Ordinal) ? repeatedAnyToolSignatureCount + 1 : 1; if (TryHandleNoProgressReadOnlyLoopTransition( call, toolCallSignature, previewRepeatedToolCount, messages, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy)) { lastAnyToolSignature = toolCallSignature; repeatedAnyToolSignatureCount = previewRepeatedToolCount; continue; } lastAnyToolSignature = toolCallSignature; repeatedAnyToolSignatureCount = previewRepeatedToolCount; if (TryHandleRepeatedFailureGuardTransition( call, toolCallSignature, messages, lastFailedToolSignature, repeatedFailedToolSignatureCount, maxRetry, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy)) { statsRepeatedFailureBlocks++; continue; } // Task Decomposition: 단계 진행률 추적 if (planSteps.Count > 0) { var summary = FormatToolCallSummary(effectiveCall); var newStep = TaskDecomposer.EstimateCurrentStep( planSteps, effectiveCall.ToolName, summary, currentStep); if (newStep != currentStep) { currentStep = newStep; EmitEvent(AgentEventType.StepStart, "", planSteps[currentStep], stepCurrent: currentStep + 1, stepTotal: planSteps.Count); } } // 개발자 모드: 도구 호출 파라미터 상세 표시 if (context.DevMode) { var paramJson = effectiveCall.ToolInput?.ToString() ?? "{}"; if (paramJson.Length > 500) paramJson = paramJson[..500] + "..."; EmitEvent(AgentEventType.Thinking, effectiveCall.ToolName, $"[DEV] 도구 호출: {effectiveCall.ToolName}\n파라미터: {paramJson}"); } EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName, FormatToolCallSummary(effectiveCall)); var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages); if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse)) return decisionTransition.TerminalResponse; if (decisionTransition.ShouldContinue) continue; if (ShouldEnforceForkExecution( enforceForkExecution, forkDelegationObserved, effectiveCall.ToolName, forkEnforcementAttempts)) { forkEnforcementAttempts++; messages.Add(new ChatMessage { Role = "system", Content = "[System:ForkExecutionEnforcement] 이 스킬은 context=fork 정책입니다.\n" + "가능하면 직접 단일 도구 실행 대신 spawn_agent로 작업을 위임하고,\n" + "필요할 때만 wait_agents로 결과를 회수하세요.\n" + "정말 직접 실행이 필요하면 이유를 짧게 설명한 뒤 진행하세요." }); EmitEvent( AgentEventType.Thinking, effectiveCall.ToolName, "[SkillRuntime] context=fork 정책으로 spawn_agent 우선 재계획을 요청했습니다."); continue; } // ── Pre-Hook 실행 ── if (llm.EnableToolHooks && runtimeHooks.Count > 0) { try { var preHooks = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, effectiveCall.ToolName, "pre"); var preResults = await AgentHookRunner.RunAsync( preHooks, effectiveCall.ToolName, "pre", toolInput: effectiveCall.ToolInput.ToString(), workFolder: context.WorkFolder, timeoutMs: llm.ToolHookTimeoutMs, ct: ct); foreach (var pr in preResults) { EmitEvent(AgentEventType.HookResult, effectiveCall.ToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); ApplyHookAdditionalContext(messages, pr); ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); } if (llm.EnableHookInputMutation && TryGetHookUpdatedInput(preResults, out var updatedInput)) { effectiveCall = new LlmService.ContentBlock { Type = effectiveCall.Type, Text = effectiveCall.Text, ToolName = effectiveCall.ToolName, ToolId = effectiveCall.ToolId, ToolInput = updatedInput, }; EmitEvent( AgentEventType.Thinking, effectiveCall.ToolName, "[Hook] updatedInput 적용: Pre-Hook 결과로 도구 입력이 갱신되었습니다."); } } catch { /* 훅 실패가 도구 실행을 차단하지 않음 */ } } ToolResult result; var sw = Stopwatch.StartNew(); try { var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement; result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct); } catch (OperationCanceledException) { EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다"); await RunRuntimeHooksAsync( "__stop_requested__", "post", JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }), "cancelled", success: false); EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다."); return "사용자가 작업을 취소했습니다."; } catch (Exception ex) { result = ToolResult.Fail($"도구 실행 오류: {ex.Message}"); } sw.Stop(); // ── Post-Hook 실행 ── if (llm.EnableToolHooks && runtimeHooks.Count > 0) { try { var postHooks = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, effectiveCall.ToolName, "post"); var postResults = await AgentHookRunner.RunAsync( postHooks, effectiveCall.ToolName, "post", toolInput: effectiveCall.ToolInput.ToString(), toolOutput: TruncateOutput(result.Output, 2048), success: result.Success, workFolder: context.WorkFolder, timeoutMs: llm.ToolHookTimeoutMs, ct: ct); foreach (var pr in postResults) { EmitEvent(AgentEventType.HookResult, effectiveCall.ToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); ApplyHookAdditionalContext(messages, pr); ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); } } catch { /* 훅 실패가 결과 처리를 차단하지 않음 */ } } // 개발자 모드: 도구 결과 상세 표시 if (context.DevMode) { EmitEvent(AgentEventType.Thinking, effectiveCall.ToolName, $"[DEV] 결과: {(result.Success ? "성공" : "실패")}\n{TruncateOutput(result.Output, 500)}"); } var tokenUsage = _llm.LastTokenUsage; EmitEvent( result.Success ? AgentEventType.ToolResult : AgentEventType.Error, effectiveCall.ToolName, TruncateOutput(result.Output, 200), result.FilePath, elapsedMs: sw.ElapsedMilliseconds, inputTokens: tokenUsage?.PromptTokens ?? 0, outputTokens: tokenUsage?.CompletionTokens ?? 0, toolInput: effectiveCall.ToolInput?.ToString(), iteration: iteration); if (string.Equals(effectiveCall.ToolName, "spawn_agent", StringComparison.OrdinalIgnoreCase)) forkDelegationObserved = true; ApplyToolPostExecutionBookkeeping( effectiveCall, result, tokenUsage, llm, baseMax, statsUsedTools, ref totalToolCalls, ref maxIterations, ref statsSuccessCount, ref statsFailCount, ref statsInputTokens, ref statsOutputTokens); if (!result.Success) { failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount); failedToolHistogram[effectiveCall.ToolName] = failedCount + 1; } // UI 스레드가 이벤트를 렌더링할 시간 확보 await Task.Delay(80, ct); if (TryHandleToolFailureTransition( effectiveCall, result, context, taskPolicy, messages, maxRetry, lastModifiedCodeFilePath, requireHighImpactCodeVerification, toolCallSignature, ref consecutiveErrors, ref recoveryPendingAfterFailure, ref lastFailedToolSignature, ref repeatedFailedToolSignatureCount)) { consecutiveReadOnlySuccessTools = 0; consecutiveNonMutatingSuccessTools = 0; continue; } else { consecutiveReadOnlySuccessTools = UpdateConsecutiveReadOnlySuccessTools( consecutiveReadOnlySuccessTools, effectiveCall.ToolName, result.Success); consecutiveNonMutatingSuccessTools = UpdateConsecutiveNonMutatingSuccessTools( consecutiveNonMutatingSuccessTools, effectiveCall.ToolName, result.Success); consecutiveErrors = 0; // 성공 시 에러 카운터 리셋 if (recoveryPendingAfterFailure) { statsRecoveredAfterFailure++; recoveryPendingAfterFailure = false; } lastFailedToolSignature = null; repeatedFailedToolSignatureCount = 0; if (!string.IsNullOrWhiteSpace(result.FilePath)) lastArtifactFilePath = result.FilePath; // 도구 결과를 LLM에 피드백 messages.Add(LlmService.CreateToolResultMessage( effectiveCall.ToolId, effectiveCall.ToolName, TruncateOutput(result.Output, 4000))); if (TryHandleReadOnlyStagnationTransition( consecutiveReadOnlySuccessTools, messages, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy)) { consecutiveReadOnlySuccessTools = 0; continue; } if (TryHandleNoProgressExecutionTransition( consecutiveNonMutatingSuccessTools, messages, lastModifiedCodeFilePath, requireHighImpactCodeVerification, taskPolicy, documentPlanPath, runState)) { consecutiveNonMutatingSuccessTools = 0; continue; } if (ShouldAbortNoProgressExecution( consecutiveNonMutatingSuccessTools, runState.NoProgressRecoveryRetry)) { EmitEvent(AgentEventType.Error, "", "비진행 상태가 장시간 지속되어 작업을 중단합니다."); return BuildNoProgressAbortResponse( taskPolicy, consecutiveNonMutatingSuccessTools, runState.NoProgressRecoveryRetry, lastArtifactFilePath, statsUsedTools); } ApplyDocumentPlanSuccessTransitions( effectiveCall, result, messages, ref documentPlanCalled, ref documentPlanPath, ref documentPlanTitle, ref documentPlanScaffold); var (terminalCompleted, consumedExtraIteration) = await TryHandleTerminalDocumentCompletionTransitionAsync( effectiveCall, result, toolCalls, messages, llm, context, ct); if (terminalCompleted) { if (consumedExtraIteration) iteration++; return result.Output; } var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync( effectiveCall, result, messages, llm, context, ct); if (consumedVerificationIteration) iteration++; // 검증 LLM 호출도 반복 횟수에 포함 ApplyCodeQualityFollowUpTransition( effectiveCall, result, messages, taskPolicy, ref requireHighImpactCodeVerification, ref lastModifiedCodeFilePath); } } } if (iteration >= maxIterations) { EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)"); return BuildIterationLimitFallbackResponse( maxIterations, taskPolicy, totalToolCalls, statsSuccessCount, statsFailCount, lastArtifactFilePath, statsUsedTools, failedToolHistogram, messages); } if (ct.IsCancellationRequested) { EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다"); await RunRuntimeHooksAsync( "__stop_requested__", "post", JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }), "cancelled", success: false); } return "(취소됨)"; } finally { if (runtimeOverrideApplied) _llm.PopInferenceOverride(); IsRunning = false; _currentRunId = ""; // 일시정지 상태 리셋 if (IsPaused) { IsPaused = false; try { _pauseSemaphore.Release(); } catch (SemaphoreFullException) { } } // 통계 기록 (도구 호출이 1회 이상인 세션만) if (totalToolCalls > 0) { var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds; AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord { Timestamp = statsStart, Tab = ActiveTab ?? "", TaskType = taskPolicy.TaskType, Model = _settings.Settings.Llm.Model ?? "", ToolCalls = totalToolCalls, SuccessCount = statsSuccessCount, FailCount = statsFailCount, InputTokens = statsInputTokens, OutputTokens = statsOutputTokens, DurationMs = durationMs, RepeatedFailureBlockedCount = statsRepeatedFailureBlocks, RecoveredAfterFailureCount = statsRecoveredAfterFailure, UsedTools = statsUsedTools, }); // 전체 호출·토큰 합계 표시 (개발자 모드 설정) if (llm.ShowTotalCallStats) { var totalTokens = statsInputTokens + statsOutputTokens; var durationSec = durationMs / 1000.0; var toolList = string.Join(", ", statsUsedTools); var retryTotal = statsRepeatedFailureBlocks + statsRecoveredAfterFailure; var retryQuality = retryTotal > 0 ? $"{(statsRecoveredAfterFailure * 100.0 / retryTotal):F0}%" : "100%"; var topFailed = BuildTopFailureSummary(failedToolHistogram); var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " + $"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " + $"소요 {durationSec:F1}초 | 재시도 품질 {retryQuality} (복구 {statsRecoveredAfterFailure}, 차단 {statsRepeatedFailureBlocks}) | " + $"실패 상위: {topFailed} | 사용 도구: {toolList}"; EmitEvent(AgentEventType.StepDone, "total_stats", summary); } } } } private SkillRuntimeOverrides? ResolveSkillRuntimeOverrides(List messages) { var policy = messages .Where(m => string.Equals(m.Role, "system", StringComparison.OrdinalIgnoreCase)) .Select(m => m.Content ?? "") .FirstOrDefault(c => c.Contains("[Skill Runtime Policy]", StringComparison.Ordinal)); if (string.IsNullOrWhiteSpace(policy)) return null; string? model = null; string? effort = null; var requireForkExecution = false; var allowedTools = new HashSet(StringComparer.OrdinalIgnoreCase); var hookNames = new HashSet(StringComparer.OrdinalIgnoreCase); var hookFilters = new List(); foreach (var raw in policy.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { var line = raw.Trim(); if (line.StartsWith("- preferred_model:", StringComparison.OrdinalIgnoreCase)) { model = line["- preferred_model:".Length..].Trim(); } else if (line.StartsWith("- reasoning_effort:", StringComparison.OrdinalIgnoreCase)) { effort = NormalizeEffortToken(line["- reasoning_effort:".Length..].Trim()); } else if (line.StartsWith("- execution_context:", StringComparison.OrdinalIgnoreCase)) { var ctx = line["- execution_context:".Length..].Trim(); requireForkExecution = string.Equals(ctx, "fork", StringComparison.OrdinalIgnoreCase); } else if (line.StartsWith("- allowed_tools:", StringComparison.OrdinalIgnoreCase)) { var rawTools = line["- allowed_tools:".Length..].Trim(); foreach (var toolName in ParseAllowedToolNames(rawTools)) allowedTools.Add(toolName); } else if (line.StartsWith("- hook_names:", StringComparison.OrdinalIgnoreCase)) { var rawHooks = line["- hook_names:".Length..].Trim(); foreach (var hookName in ParseHookNames(rawHooks)) hookNames.Add(hookName); } else if (line.StartsWith("- hook_filters:", StringComparison.OrdinalIgnoreCase)) { var rawFilters = line["- hook_filters:".Length..].Trim(); hookFilters.AddRange(ParseHookFilters(rawFilters)); } } if (string.IsNullOrWhiteSpace(model) && string.IsNullOrWhiteSpace(effort) && !requireForkExecution && allowedTools.Count == 0 && hookNames.Count == 0 && hookFilters.Count == 0) return null; var service = ResolveServiceForModel(model); var temperature = ResolveTemperatureForEffort(effort); return new SkillRuntimeOverrides( service, model, temperature, effort, requireForkExecution, allowedTools, hookNames, hookFilters.AsReadOnly()); } private IReadOnlyCollection GetRuntimeActiveTools( IEnumerable? disabledToolNames, SkillRuntimeOverrides? runtimeOverrides) { var mergedDisabled = MergeDisabledTools(disabledToolNames); var active = _tools.GetActiveTools(mergedDisabled); if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) return active; return active .Where(t => runtimeOverrides.AllowedToolNames.Contains(t.Name)) .ToList() .AsReadOnly(); } private IEnumerable MergeDisabledTools(IEnumerable? disabledToolNames) { var disabled = new HashSet(StringComparer.OrdinalIgnoreCase); if (disabledToolNames != null) { foreach (var name in disabledToolNames) { if (!string.IsNullOrWhiteSpace(name)) disabled.Add(name); } } if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) return disabled; var code = _settings.Settings.Llm.Code; if (!code.EnablePlanModeTools) { disabled.Add("enter_plan_mode"); disabled.Add("exit_plan_mode"); } if (!code.EnableWorktreeTools) { disabled.Add("enter_worktree"); disabled.Add("exit_worktree"); } if (!code.EnableTeamTools) { disabled.Add("team_create"); disabled.Add("team_delete"); } if (!code.EnableCronTools) { disabled.Add("cron_create"); disabled.Add("cron_delete"); disabled.Add("cron_list"); } return disabled; } private static HashSet ParseAllowedToolNames(string? raw) { var result = new HashSet(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(raw)) return result; foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var normalized = token.Trim().Trim('`', '"', '\''); if (string.IsNullOrWhiteSpace(normalized)) continue; var alias = NormalizeAliasToken(normalized); if (ToolAliasMap.TryGetValue(alias, out var mapped)) normalized = mapped; result.Add(normalized); } return result; } private static HashSet ParseHookNames(string? raw) { var result = new HashSet(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(raw)) return result; foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var normalized = token.Trim().Trim('`', '"', '\''); if (!string.IsNullOrWhiteSpace(normalized)) result.Add(normalized); } return result; } private static List ParseHookFilters(string? raw) { var result = new List(); if (string.IsNullOrWhiteSpace(raw)) return result; var order = 0; foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var normalized = token.Trim().Trim('`', '"', '\''); if (string.IsNullOrWhiteSpace(normalized)) continue; var parts = normalized.Split('@', StringSplitOptions.TrimEntries); var rawHookName = parts.Length > 0 && !string.IsNullOrWhiteSpace(parts[0]) ? parts[0] : "*"; var isExclude = rawHookName.StartsWith('!'); var hookName = isExclude ? rawHookName[1..].Trim() : rawHookName; if (string.IsNullOrWhiteSpace(hookName)) hookName = "*"; var timing = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "*"; var toolName = parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2]) ? parts[2] : "*"; var specificity = (hookName == "*" ? 0 : 1) + (timing == "*" ? 0 : 1) + (toolName == "*" ? 0 : 1); result.Add(new HookFilterRule(hookName, timing, toolName, isExclude, specificity, order++)); } return result; } private static IReadOnlyList GetRuntimeHooks( IReadOnlyList allHooks, SkillRuntimeOverrides? runtimeOverrides) { if (runtimeOverrides == null || runtimeOverrides.HookNames.Count == 0) return allHooks.ToList().AsReadOnly(); if (runtimeOverrides.HookNames.Contains("*")) return allHooks.ToList().AsReadOnly(); return allHooks .Where(h => runtimeOverrides.HookNames.Contains(h.Name)) .ToList() .AsReadOnly(); } private static IReadOnlyList GetRuntimeHooksForCall( IReadOnlyList hooks, SkillRuntimeOverrides? runtimeOverrides, string toolName, string timing) { if (runtimeOverrides == null || runtimeOverrides.HookFilters.Count == 0) return hooks; return hooks .Where(h => { var matches = runtimeOverrides.HookFilters .Where(f => IsHookFilterMatch(f, h.Name, timing, toolName)) .OrderByDescending(f => f.Specificity) .ThenByDescending(f => f.Order) .ToList(); if (matches.Count == 0) return false; // 가장 구체적인 규칙이 우선. 동점일 때는 뒤에 선언된 규칙 우선. return !matches[0].IsExclude; }) .ToList() .AsReadOnly(); } private static bool IsHookFilterMatch(HookFilterRule filter, string hookName, string timing, string toolName) => IsHookFilterTokenMatch(filter.HookName, hookName) && IsHookFilterTokenMatch(filter.Timing, timing) && IsHookFilterTokenMatch(filter.ToolName, toolName); private static bool IsHookFilterTokenMatch(string pattern, string value) => pattern == "*" || string.Equals(pattern, value, StringComparison.OrdinalIgnoreCase); private string ResolveServiceForModel(string? model) { var llm = _settings.Settings.Llm; if (string.IsNullOrWhiteSpace(model)) return llm.Service; var matched = llm.RegisteredModels.FirstOrDefault(m => string.Equals(m.Alias, model, StringComparison.OrdinalIgnoreCase) || string.Equals( CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled), model, StringComparison.OrdinalIgnoreCase)); return matched?.Service ?? llm.Service; } private static string? NormalizeEffortToken(string? effort) { if (string.IsNullOrWhiteSpace(effort)) return null; return effort.Trim().ToLowerInvariant() switch { "low" => "low", "medium" => "medium", "high" => "high", "xhigh" => "high", _ => null, }; } private static double? ResolveTemperatureForEffort(string? effort) { return effort switch { "low" => 0.6, "high" => 0.2, _ => null, }; } private static bool IsForkCompliantTool(string toolName) => toolName is "spawn_agent" or "wait_agents"; private static bool ShouldEnforceForkExecution( bool enforceForkExecution, bool forkDelegationObserved, string toolName, int forkEnforcementAttempts, int maxForkEnforcementAttempts = 2) { if (!enforceForkExecution) return false; if (forkDelegationObserved) return false; if (forkEnforcementAttempts >= maxForkEnforcementAttempts) return false; return !IsForkCompliantTool(toolName); } private sealed record SkillRuntimeOverrides( string? Service, string? Model, double? Temperature, string? ReasoningEffort, bool RequireForkExecution, IReadOnlySet AllowedToolNames, IReadOnlySet HookNames, IReadOnlyList HookFilters); private sealed record HookFilterRule( string HookName, string Timing, string ToolName, bool IsExclude, int Specificity, int Order); /// LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다. private string? AutoSaveAsHtml(string textContent, string userQuery, AgentContext context) { try { // 파일명 생성 — 동사/명령어를 제거하여 깔끔한 파일명 만들기 var title = userQuery.Length > 60 ? userQuery[..60] : userQuery; // 파일명에 불필요한 동사/명령어 제거 var removeWords = new[] { "작성해줘", "작성해 줘", "만들어줘", "만들어 줘", "써줘", "써 줘", "생성해줘", "생성해 줘", "작성해", "만들어", "생성해", "해줘", "해 줘", "부탁해" }; var safeTitle = title; foreach (var w in removeWords) safeTitle = safeTitle.Replace(w, "", StringComparison.OrdinalIgnoreCase); foreach (var c in System.IO.Path.GetInvalidFileNameChars()) safeTitle = safeTitle.Replace(c, '_'); safeTitle = safeTitle.Trim().TrimEnd('.').Trim(); var fileName = $"{safeTitle}.html"; var fullPath = FileReadTool.ResolvePath(fileName, context.WorkFolder); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); var dir = System.IO.Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir); // 텍스트 → HTML 변환 var css = TemplateService.GetCss("professional"); var htmlBody = ConvertTextToHtml(textContent); var html = $@" {EscapeHtml(title)}

{EscapeHtml(title)}

작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성
{htmlBody}
"; System.IO.File.WriteAllText(fullPath, html, System.Text.Encoding.UTF8); LogService.Info($"[AgentLoop] 문서 자동 저장 완료: {fullPath}"); return fullPath; } catch (Exception ex) { LogService.Warn($"[AgentLoop] 문서 자동 저장 실패: {ex.Message}"); return null; } } /// LLM 텍스트(마크다운 형식)를 HTML로 변환합니다. private static string ConvertTextToHtml(string text) { var sb = new System.Text.StringBuilder(); var lines = text.Split('\n'); var inList = false; var listType = "ul"; foreach (var rawLine in lines) { var line = rawLine.TrimEnd(); // 빈 줄 if (string.IsNullOrWhiteSpace(line)) { if (inList) { sb.AppendLine($""); inList = false; } continue; } // 마크다운 제목 if (line.StartsWith("### ")) { if (inList) { sb.AppendLine($""); inList = false; } sb.AppendLine($"

{EscapeHtml(line[4..])}

"); continue; } if (line.StartsWith("## ")) { if (inList) { sb.AppendLine($""); inList = false; } sb.AppendLine($"

{EscapeHtml(line[3..])}

"); continue; } if (line.StartsWith("# ")) { if (inList) { sb.AppendLine($""); inList = false; } sb.AppendLine($"

{EscapeHtml(line[2..])}

"); continue; } // 번호 리스트 (1. 2. 등) - 대제목급이면 h2로 if (System.Text.RegularExpressions.Regex.IsMatch(line, @"^\d+\.\s+\S")) { var content = System.Text.RegularExpressions.Regex.Replace(line, @"^\d+\.\s+", ""); // 짧고 제목 같으면 h2, 길면 리스트 if (content.Length < 80 && !content.Contains('.') && !line.StartsWith(" ")) { if (inList) { sb.AppendLine($""); inList = false; } sb.AppendLine($"

{EscapeHtml(line)}

"); } else { if (!inList) { sb.AppendLine("
    "); inList = true; listType = "ol"; } sb.AppendLine($"
  1. {EscapeHtml(content)}
  2. "); } continue; } // 불릿 리스트 if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• ")) { var content = line.TrimStart()[2..].Trim(); if (!inList) { sb.AppendLine("
      "); inList = true; listType = "ul"; } sb.AppendLine($"
    • {EscapeHtml(content)}
    • "); continue; } // 일반 텍스트 if (inList) { sb.AppendLine($""); inList = false; } sb.AppendLine($"

      {EscapeHtml(line)}

      "); } if (inList) sb.AppendLine($""); return sb.ToString(); } private static string EscapeHtml(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); /// 사용자 요청이 문서/보고서 생성인지 판단합니다. private static bool IsDocumentCreationRequest(string query) { if (string.IsNullOrWhiteSpace(query)) return false; // 문서 생성 관련 키워드 패턴 var keywords = new[] { "보고서", "리포트", "report", "문서", "작성해", "써줘", "써 줘", "만들어", "분석서", "제안서", "기획서", "회의록", "매뉴얼", "가이드", "excel", "엑셀", "docx", "word", "html", "pptx", "ppt", "프레젠테이션", "발표자료", "슬라이드" }; var q = query.ToLowerInvariant(); return keywords.Any(k => q.Contains(k, StringComparison.OrdinalIgnoreCase)); } /// 문서 생성 도구인지 확인합니다 (Cowork 검증 대상). private static bool IsDocumentCreationTool(string toolName) { return toolName is "file_write" or "docx_create" or "html_create" or "excel_create" or "csv_create" or "script_create" or "pptx_create"; } /// /// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다. /// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다. /// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다. /// private static bool IsTerminalDocumentTool(string toolName) { return toolName is "html_create" or "docx_create" or "excel_create" or "pptx_create" or "document_assemble" or "csv_create"; } /// 코드 생성/수정 도구인지 확인합니다 (Code 검증 대상). private static bool IsCodeVerificationTarget(string toolName) { return toolName is "file_write" or "file_edit" or "file_manage" or "script_create" or "process"; // 빌드/테스트 실행 결과 검증 } private static bool ShouldInjectCodeQualityFollowUp(string activeTab, string toolName, ToolResult result) { if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) || !result.Success) return false; return toolName is "file_write" or "file_edit" or "file_manage" or "script_create"; } private static bool IsHighImpactCodeModification(string activeTab, string toolName, ToolResult result) { if (!ShouldInjectCodeQualityFollowUp(activeTab, toolName, result)) return false; return IsHighImpactCodePath(result.FilePath) || ContainsAny(result.Output, "public class", "public interface", "public record", "partial class", "service", "repository", "controller", "viewmodel", "program.cs", "startup", "dependency injection", "registration"); } private static bool IsHighImpactCodePath(string? filePath) { if (string.IsNullOrWhiteSpace(filePath)) return false; var normalized = filePath.Replace('\\', '/'); var fileName = System.IO.Path.GetFileName(filePath); if (fileName.Equals("Program.cs", StringComparison.OrdinalIgnoreCase) || fileName.Equals("Startup.cs", StringComparison.OrdinalIgnoreCase) || fileName.Equals("App.xaml.cs", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) return true; if (fileName.StartsWith("I", StringComparison.OrdinalIgnoreCase) && fileName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return true; return normalized.Contains("/services/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/controllers/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/repositories/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/models/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/viewmodels/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/interfaces/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/contracts/", StringComparison.OrdinalIgnoreCase) || normalized.Contains("/sdk/", StringComparison.OrdinalIgnoreCase); } private static bool IsProductionCodePath(string? filePath) { if (string.IsNullOrWhiteSpace(filePath)) return false; var normalized = filePath.Replace('\\', '/'); return normalized.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && !normalized.Contains("/tests/", StringComparison.OrdinalIgnoreCase) && !normalized.Contains(".tests/", StringComparison.OrdinalIgnoreCase) && !normalized.Contains("/test/", StringComparison.OrdinalIgnoreCase); } internal static string ClassifyTaskType(string? userQuery, string? activeTab) { var q = userQuery ?? ""; if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검")) return "review"; if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패")) return "bugfix"; if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선")) return "refactor"; if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) && ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서")) return "docs"; if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능")) return "feature"; return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general"; } private static void InjectTaskTypeGuidance(List messages, TaskTypePolicy taskPolicy) { if (messages.Any(m => m.Role == "user" && m.Content.StartsWith("[System:TaskType]", StringComparison.OrdinalIgnoreCase))) return; var guidance = BuildTaskTypeGuidanceMessage(taskPolicy); messages.Insert(0, new ChatMessage { Role = "user", Content = guidance }); } private static void InjectTaskTypeGuidance(List messages, string taskType) => InjectTaskTypeGuidance(messages, TaskTypePolicy.FromTaskType(taskType)); internal static string BuildTaskTypeGuidanceMessage(TaskTypePolicy taskPolicy) => taskPolicy.GuidanceMessage; internal static string BuildTaskTypeGuidanceMessage(string taskType) => BuildTaskTypeGuidanceMessage(TaskTypePolicy.FromTaskType(taskType)); private static void InjectRecentFailureGuidance(List messages, AgentContext context, string userQuery, TaskTypePolicy taskPolicy) { if (messages.Any(m => m.Role == "user" && m.Content.StartsWith("[System:FailurePatterns]", StringComparison.OrdinalIgnoreCase))) return; try { var app = System.Windows.Application.Current as App; var memSvc = app?.MemoryService; if (memSvc == null || !(app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false)) return; memSvc.Load(context.WorkFolder); var relevant = memSvc.GetRelevant(userQuery, 12); var allPatternEntries = relevant .Where(e => string.Equals(e.Type, "correction", StringComparison.OrdinalIgnoreCase)) .Where(e => !string.IsNullOrWhiteSpace(e.Content) && e.Content.Contains("code-failure", StringComparison.OrdinalIgnoreCase)) .Where(e => e.Content.Contains("task:", StringComparison.OrdinalIgnoreCase)) .ToList(); var patterns = SelectTopFailurePatterns(allPatternEntries, taskPolicy, 3); var guidance = BuildFailurePatternGuidance(patterns, taskPolicy); if (string.IsNullOrWhiteSpace(guidance)) return; messages.Insert(0, new ChatMessage { Role = "user", Content = guidance }); } catch { } } private static bool IsFailurePatternForTaskType(string pattern, string taskType) { if (!TryParseFailurePattern(pattern, out var parsed)) return false; return string.Equals(parsed.TaskType, taskType, StringComparison.OrdinalIgnoreCase); } internal static IReadOnlyList SelectTopFailurePatterns( IEnumerable patternEntries, TaskTypePolicy taskPolicy, int maxCount = 3) { var deduped = patternEntries .Where(e => !string.IsNullOrWhiteSpace(e.Content)) .Select(e => new { e.Content, Score = ScoreFailurePattern(e, taskPolicy), Signature = BuildFailurePatternSignature(e.Content) }) .GroupBy(x => x.Signature, StringComparer.OrdinalIgnoreCase) .Select(g => g .OrderByDescending(x => x.Score) .ThenByDescending(x => ExtractRetryCurrent(x.Content)) .First()) .ToList(); var candidates = deduped .OrderByDescending(x => x.Score) .ThenByDescending(x => ExtractRetryCurrent(x.Content)) .Take(Math.Max(1, maxCount)) .Select(x => x.Content) .ToList(); return candidates; } internal static IReadOnlyList SelectTopFailurePatterns( IEnumerable patternEntries, string taskType, int maxCount = 3) => SelectTopFailurePatterns(patternEntries, TaskTypePolicy.FromTaskType(taskType), maxCount); private static double ScoreFailurePattern(MemoryEntry entry, TaskTypePolicy taskPolicy) { var pattern = entry.Content ?? ""; var score = Math.Max(0, entry.Relevance); var parsed = TryParseFailurePattern(pattern, out var parsedPattern) ? parsedPattern : null; if (parsed != null && string.Equals(parsed.TaskType, taskPolicy.TaskType, StringComparison.OrdinalIgnoreCase)) score += 2.0; else if (parsed != null && !string.Equals(parsed.TaskType, "general", StringComparison.OrdinalIgnoreCase)) score += 0.5; else score -= 0.2; if (parsed?.IsHighImpact == true) score += 0.8; var retryCurrent = parsed?.RetryCurrent ?? 0; if (retryCurrent > 0) score += Math.Min(retryCurrent * 0.3, 1.2); var ageDays = (DateTime.Now - entry.LastUsedAt).TotalDays; if (ageDays <= 1) score += 1.0; else if (ageDays <= 7) score += 0.6; else if (ageDays <= 30) score += 0.3; score += Math.Min(entry.UseCount * 0.05, 0.3); return score; } private static int ExtractRetryCurrent(string pattern) { if (!TryParseFailurePattern(pattern, out var parsed)) return 0; return parsed.RetryCurrent; } private sealed class ParsedFailurePattern { public string TaskType { get; init; } = ""; public bool IsHighImpact { get; init; } public int RetryCurrent { get; init; } public string FailureKind { get; init; } = ""; public string ToolName { get; init; } = ""; public string ErrorSnippet { get; init; } = ""; } private static bool TryParseFailurePattern(string pattern, out ParsedFailurePattern parsed) { parsed = new ParsedFailurePattern(); if (string.IsNullOrWhiteSpace(pattern)) return false; var segments = pattern .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length == 0) return false; var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var segment in segments) { var colon = segment.IndexOf(':'); if (colon <= 0) continue; var key = segment[..colon].Trim(); var value = segment[(colon + 1)..].Trim(); if (string.IsNullOrWhiteSpace(key)) continue; map[key] = value; } if (!map.TryGetValue("task", out var taskType) || string.IsNullOrWhiteSpace(taskType)) return false; var isHighImpact = map.TryGetValue("impact", out var impact) && string.Equals(impact, "high", StringComparison.OrdinalIgnoreCase); var failureKind = map.TryGetValue("kind", out var kind) ? (kind ?? "").Trim().ToLowerInvariant() : ""; var toolName = map.TryGetValue("tool", out var tool) ? (tool ?? "").Trim() : ""; var errorSnippet = map.TryGetValue("error", out var err) ? (err ?? "").Trim() : ""; if (errorSnippet.Length > 64) errorSnippet = errorSnippet[..64]; var retryCurrent = 0; if (map.TryGetValue("retry", out var retryText)) { var slash = retryText.IndexOf('/'); var currentPart = slash >= 0 ? retryText[..slash] : retryText; if (int.TryParse(currentPart.Trim(), out var parsedRetry)) retryCurrent = Math.Max(0, parsedRetry); } parsed = new ParsedFailurePattern { TaskType = taskType.Trim(), IsHighImpact = isHighImpact, RetryCurrent = retryCurrent, FailureKind = failureKind, ToolName = toolName, ErrorSnippet = errorSnippet, }; return true; } private static string BuildFailurePatternSignature(string pattern) { if (string.IsNullOrWhiteSpace(pattern)) return "unknown"; var segments = pattern .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var segment in segments) { var colon = segment.IndexOf(':'); if (colon <= 0) continue; var key = segment[..colon].Trim(); var value = segment[(colon + 1)..].Trim(); if (!string.IsNullOrWhiteSpace(key)) map[key] = value; } map.TryGetValue("task", out var task); map.TryGetValue("kind", out var kind); map.TryGetValue("tool", out var tool); map.TryGetValue("error", out var error); var errorHead = string.IsNullOrWhiteSpace(error) ? "" : error.Replace(" ", "", StringComparison.Ordinal).ToLowerInvariant(); if (errorHead.Length > 40) errorHead = errorHead[..40]; return $"{task?.Trim().ToLowerInvariant()}|{kind?.Trim().ToLowerInvariant()}|{tool?.Trim().ToLowerInvariant()}|{errorHead}"; } private static void InjectRecentFailureGuidance(List messages, AgentContext context, string userQuery, string taskType) => InjectRecentFailureGuidance(messages, context, userQuery, TaskTypePolicy.FromTaskType(taskType)); internal static string BuildFailurePatternGuidance(IReadOnlyCollection patterns, TaskTypePolicy taskPolicy) { if (patterns.Count == 0) return ""; var parsedPatterns = patterns .Select(p => TryParseFailurePattern(p, out var parsed) ? parsed : null) .Where(p => p != null) .Cast() .ToList(); var lines = patterns .Select(p => TruncateOutput(p, 220)) .Select(p => $"- {p}") .ToList(); if (lines.Count == 0) return ""; var actionHints = BuildFailurePatternActionHints(taskPolicy, parsedPatterns); return "[System:FailurePatterns] 최근 유사 실패 패턴이 있습니다. 같은 실수를 반복하지 않도록 먼저 점검하세요.\n" + $"중점: {taskPolicy.FailurePatternFocus}\n" + actionHints + "\n" + string.Join("\n", lines); } internal static string BuildFailurePatternGuidance(IReadOnlyCollection patterns, string taskType) => BuildFailurePatternGuidance(patterns, TaskTypePolicy.FromTaskType(taskType)); private static string BuildFailurePatternActionHints( TaskTypePolicy taskPolicy, IReadOnlyCollection parsedPatterns) { if (parsedPatterns.Count == 0) return "실행 지침: 같은 명령/파라미터 반복을 금지하고, 읽기 근거(file_read/grep/git_tool) 후에만 재시도하세요."; var repeatedTools = parsedPatterns .Select(p => (p.ToolName ?? "").Trim().ToLowerInvariant()) .Where(x => !string.IsNullOrWhiteSpace(x)) .GroupBy(x => x) .OrderByDescending(g => g.Count()) .Take(2) .Select(g => g.Key) .ToList(); var toolHint = repeatedTools.Count == 0 ? "실패 도구 공통 패턴" : string.Join(", ", repeatedTools); var hasBuildLoopRisk = repeatedTools.Any(t => t.Contains("build_run") || t.Contains("test_loop")); var priority = hasBuildLoopRisk ? "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop" : "file_read -> grep/glob -> git_tool(diff) -> targeted retry"; var dominantFailureKinds = parsedPatterns .Select(p => (p.FailureKind ?? "").Trim().ToLowerInvariant()) .Where(x => !string.IsNullOrWhiteSpace(x)) .GroupBy(x => x) .OrderByDescending(g => g.Count()) .Take(2) .Select(g => g.Key) .ToList(); var kindHint = dominantFailureKinds.Count == 0 ? "일반 실패" : string.Join(", ", dominantFailureKinds.Select(DescribeFailureKindToken)); var taskHint = taskPolicy.TaskType switch { "bugfix" => "재현 경로와 원인 연결을 먼저 확인하세요.", "feature" => "호출부 영향과 사용자 흐름을 먼저 확인하세요.", "refactor" => "동작 보존 근거를 먼저 확인하세요.", "review" => "추측이 아닌 결함 근거를 먼저 확보하세요.", _ => "실패 로그 근거를 먼저 확보하세요." }; return "실행 지침:\n" + $"- 반복 경향 도구: {toolHint}\n" + $"- 반복 실패 유형: {kindHint}\n" + $"- 우선순위: {priority}\n" + "- 금지: 코드 변경 없이 동일 build/test 명령 재실행\n" + $"- 작업 힌트: {taskHint}"; } private static string DescribeFailureKindToken(string token) { return token switch { "permission" => "권한", "path" => "경로", "command" => "명령/파라미터", "dependency" => "의존성/환경", "timeout" => "타임아웃", _ => "일반", }; } private static string BuildCodeQualityFollowUpPrompt(string toolName, ToolResult result, bool highImpact, bool hasBaselineBuildOrTest, TaskTypePolicy taskPolicy) { var fileRef = string.IsNullOrWhiteSpace(result.FilePath) ? "방금 수정한 코드" : $"'{result.FilePath}'"; var requiresTestReview = IsProductionCodePath(result.FilePath); var testReviewLine = requiresTestReview ? "4. grep/glob으로 관련 테스트 파일과 테스트 진입점을 찾아 기대 동작과 영향 범위를 확인합니다.\n" + " 관련 테스트를 찾지 못했다면, 찾으려 했던 근거와 테스트 부재 사실을 명시적으로 기록합니다.\n" : ""; var baselineLine = hasBaselineBuildOrTest ? "" : "참고: 아직 baseline build/test 근거가 없습니다. 가능하면 지금 build/test를 실행해 수정 전후 차이를 설명할 수 있게 하세요.\n"; var taskTypeLine = taskPolicy.FollowUpTaskLine; var extraSteps = highImpact ? testReviewLine + "5. grep 또는 glob으로 공용 API, 호출부, 의존성 등록, 테스트 영향을 모두 다시 확인합니다.\n" + "6. build_run으로 build와 test를 모두 실행해 통과 여부를 확인합니다.\n" + "7. 필요하면 spawn_agent로 호출부 분석이나 관련 테스트 탐색을 병렬 조사하게 하고, wait_agents로 결과를 통합합니다.\n" + "8. 문제가 발견되면 즉시 수정하고 다시 검증합니다.\n" + "중요: 이 변경은 영향 범위가 넓을 가능성이 큽니다. build/test와 참조 검토가 모두 끝나기 전에는 마무리하지 마세요." : testReviewLine + "4. build_run으로 build/test를 실행해 결과를 확인합니다.\n" + "5. 문제가 발견되면 즉시 수정하고 다시 검증합니다.\n" + "중요: 검증 근거(build/test/file_read/diff)가 확보되기 전에는 작업을 마무리하지 마세요."; return $"[System] {toolName}로 {fileRef} 변경이 완료되었습니다. 이제 결과물 품질을 높이기 위해 다음 순서대로 진행하세요.\n" + baselineLine + taskTypeLine + "1. file_read로 방금 수정한 파일을 다시 읽어 실제 반영 상태를 확인합니다.\n" + "2. grep 또는 glob으로 영향받는 호출부/참조 지점을 다시 확인합니다.\n" + "3. git_tool diff 또는 동등한 검토 도구로 변경 범위를 확인합니다.\n" + extraSteps; } private static string BuildCodeQualityFollowUpPrompt(string toolName, ToolResult result, bool highImpact, bool hasBaselineBuildOrTest, string taskType) => BuildCodeQualityFollowUpPrompt(toolName, result, highImpact, hasBaselineBuildOrTest, TaskTypePolicy.FromTaskType(taskType)); private static string BuildFailureReflectionMessage( string toolName, ToolResult result, int consecutiveErrors, int maxRetry, TaskTypePolicy taskPolicy) { var failureKind = ClassifyFailureRecoveryKind(toolName, result.Output); var failureHint = BuildFailureTypeRecoveryHint(failureKind, toolName); var fallbackSequence = BuildFallbackToolSequenceHint(toolName, taskPolicy); if (toolName is "build_run" or "test_loop") { return $"[Tool '{toolName}' failed: {TruncateOutput(result.Output, 500)}]\n" + "Before retrying, do all of the following:\n" + "1. Re-read the files you changed most recently.\n" + "2. Inspect impacted callers/references with grep or glob.\n" + "3. Review the current diff to confirm what actually changed.\n" + "4. Only then fix the root cause and re-run build/test.\n" + failureHint + fallbackSequence + $"Do not blindly retry the same command. (Error {consecutiveErrors}/{maxRetry})"; } return $"[Tool '{toolName}' failed: {TruncateOutput(result.Output, 500)}]\n" + "Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " + failureHint + fallbackSequence + $"Try a different approach. (Error {consecutiveErrors}/{maxRetry})"; } private static string BuildFailureReflectionMessage(string toolName, ToolResult result, int consecutiveErrors, int maxRetry) => BuildFailureReflectionMessage( toolName, result, consecutiveErrors, maxRetry, TaskTypePolicy.FromTaskType("general")); private static string BuildFailureReflectionMessage( string toolName, ToolResult result, int consecutiveErrors, int maxRetry, string taskType) => BuildFailureReflectionMessage( toolName, result, consecutiveErrors, maxRetry, TaskTypePolicy.FromTaskType(taskType)); private static string BuildFallbackToolSequenceHint(string toolName, TaskTypePolicy taskPolicy) { var sequence = toolName switch { "build_run" or "test_loop" => "Fallback sequence: file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop.\n", "file_edit" or "file_write" => "Fallback sequence: file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop.\n", _ => "Fallback sequence: file_read -> grep/glob -> git_tool(diff) -> targeted tool retry.\n", }; var taskHint = taskPolicy.TaskType switch { "bugfix" => "Task hint: validate repro/root-cause link before retry.\n", "feature" => "Task hint: re-check behavior flow and caller impact before retry.\n", "refactor" => "Task hint: confirm behavior-preservation evidence before retry.\n", "review" => "Task hint: prioritize concrete defect evidence over summary.\n", _ => "" }; return sequence + taskHint; } private static int ComputeAdaptiveMaxRetry(int baseMaxRetry, string taskType) { var baseline = baseMaxRetry > 0 ? baseMaxRetry : 3; var adjusted = taskType switch { "bugfix" => baseline + 1, "feature" => baseline, "refactor" => baseline - 1, "review" => baseline - 1, "docs" => baseline - 1, _ => baseline }; return Math.Clamp(adjusted, 2, 6); } private static double? TryGetRecentTaskRetryQuality(string taskType) { try { var summary = AgentStatsService.Aggregate(30); if (summary.RetryQualityByTaskType.TryGetValue(taskType, out var quality)) return Math.Clamp(quality, 0.0, 1.0); } catch { } return null; } private static int ComputeQualityAwareMaxRetry(int currentMaxRetry, double? retryQuality, string taskType) { if (!retryQuality.HasValue) return Math.Clamp(currentMaxRetry, 2, 6); var adjusted = currentMaxRetry; if (retryQuality.Value < 0.45) adjusted += 1; else if (retryQuality.Value > 0.85 && taskType is not "bugfix") adjusted -= 1; return Math.Clamp(adjusted, 2, 6); } private static string BuildFailureInvestigationPrompt(string toolName, string? lastModifiedCodeFilePath, bool highImpactChange, TaskTypePolicy taskPolicy, string? toolOutput = null) { var fileLine = string.IsNullOrWhiteSpace(lastModifiedCodeFilePath) ? "1. 최근 수정한 파일을 file_read로 다시 읽습니다.\n" : $"1. 최근 수정한 파일 '{lastModifiedCodeFilePath}'를 file_read로 다시 읽습니다.\n"; var taskTypeLine = taskPolicy.FailureInvestigationTaskLine; var failureHint = BuildFailureTypeRecoveryHint(ClassifyFailureRecoveryKind(toolName, toolOutput), toolName); var highImpactLine = highImpactChange ? "4. 공용 API, 인터페이스, DI 등록, 모델 계약, 호출부 전파 영향까지 반드시 확인합니다.\n" + "5. 필요하면 spawn_agent로 호출부/관련 테스트 조사를 병렬 실행해 근거를 보강합니다.\n" + "6. 관련 테스트가 없다면 테스트 부재 사실과 확인 근거를 남깁니다.\n" + "7. 근본 원인을 수정한 뒤 build/test를 다시 실행합니다.\n" + "중요: 이 변경은 영향 범위가 넓을 수 있으므로 build와 test 둘 다 확인하기 전에는 종료하지 마세요." : "4. 근본 원인을 수정한 뒤 build/test를 다시 실행합니다.\n" + "중요: 같은 명령만 반복 재실행하지 말고, 반드시 근거를 읽고 수정하세요."; return "[System:FailureInvestigation] build/test가 실패했습니다. 다음 순서로 원인을 찾으세요.\n" + fileLine + "2. grep/glob으로 영향받는 호출부와 참조 지점을 확인합니다.\n" + "3. git diff 또는 동등한 방법으로 실제 변경 범위를 검토합니다.\n" + failureHint + taskTypeLine + highImpactLine; } private static string BuildFailureInvestigationPrompt(string toolName, string? lastModifiedCodeFilePath, bool highImpactChange, string taskType) => BuildFailureInvestigationPrompt(toolName, lastModifiedCodeFilePath, highImpactChange, TaskTypePolicy.FromTaskType(taskType), null); private enum FailureRecoveryKind { Unknown, Permission, Path, Command, Dependency, Timeout, } private static FailureRecoveryKind ClassifyFailureRecoveryKind(string toolName, string? output) { var text = ((output ?? "") + "\n" + toolName).ToLowerInvariant(); if (ContainsAny(text, "permission", "denied", "forbidden", "unauthorized", "access is denied", "권한", "차단", "승인")) return FailureRecoveryKind.Permission; if (ContainsAny(text, "not found", "no such file", "cannot find", "directory not found", "path", "경로", "파일을 찾을", "존재하지")) return FailureRecoveryKind.Path; if (ContainsAny(text, "command not found", "not recognized", "unknown option", "invalid argument", "syntax", "잘못된 옵션", "명령", "인식할 수")) return FailureRecoveryKind.Command; if (ContainsAny(text, "module not found", "package not found", "sdk", "runtime", "missing dependency", "의존성", "설치되지")) return FailureRecoveryKind.Dependency; if (ContainsAny(text, "timeout", "timed out", "time out", "시간 초과")) return FailureRecoveryKind.Timeout; return FailureRecoveryKind.Unknown; } private static string BuildFailureTypeRecoveryHint(FailureRecoveryKind kind, string toolName) { return kind switch { FailureRecoveryKind.Permission => "원인 분류: 권한 이슈. Ask/Auto/Deny 또는 대상 경로 접근 정책을 먼저 확인하고, 차단 경로면 우회 경로를 선택하세요.\n", FailureRecoveryKind.Path => "원인 분류: 경로 이슈. 실제 파일/폴더 존재 여부와 작업 디렉터리(cwd)를 확인한 뒤 절대경로 기준으로 재시도하세요.\n", FailureRecoveryKind.Command => $"원인 분류: 명령/파라미터 이슈. '{toolName}' 입력 파라미터와 옵션 이름을 재검토하고, 최소 파라미터로 먼저 검증하세요.\n", FailureRecoveryKind.Dependency => "원인 분류: 의존성/환경 이슈. 설치/버전 누락 여부를 확인하고, 대체 가능한 도구 흐름(file_read/grep/git_tool)으로 우회하세요.\n", FailureRecoveryKind.Timeout => "원인 분류: 타임아웃. 대상 범위를 축소(파일/테스트 필터)해 더 짧은 실행 단위로 분해한 뒤 재시도하세요.\n", _ => "원인 분류: 일반 실패. 로그 핵심 줄을 먼저 추출한 뒤 경로/파라미터/환경 순서로 원인을 좁히세요.\n", }; } /// /// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다. /// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다. /// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다. /// /// 읽기 전용 검증 도구 목록 (file_read만 허용) private static readonly HashSet VerificationAllowedTools = ["file_read", "directory_list"]; private static bool ContainsAny(string text, params string[] keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); private static bool IsVerificationIssueDetected(string response, bool isCodeTab) { if (string.IsNullOrWhiteSpace(response)) return false; var normalized = response.Replace(" ", "", StringComparison.Ordinal) .Replace("\r", "", StringComparison.Ordinal) .Replace("\n", "", StringComparison.Ordinal); var explicitPassMarkers = new[] { "문제없음", "이상없음", "수정불필요", "추가수정불필요", "검증통과", "문제발견되지않음" }; if (explicitPassMarkers.Any(marker => normalized.Contains(marker, StringComparison.OrdinalIgnoreCase))) return false; var commonIssueKeywords = new[] { "문제", "수정", "누락", "오류", "잘못", "부족", "실패", "깨짐", "불일치", "미완성", "미흡", "경고", "보완" }; var codeIssueKeywords = new[] { "compile", "compilation", "syntax", "type error", "null reference", "unused", "failing test", "빌드 실패", "테스트 실패", "구문 오류", "타입 오류", "참조 오류", "영향 범위", "호출부", "회귀", "예외 처리 누락" }; return ContainsAny(response, commonIssueKeywords) || (isCodeTab && ContainsAny(response, codeIssueKeywords)); } private static bool TryGetToolResultToolName(ChatMessage message, out string toolName) { return TryGetToolResultInfo(message, out toolName, out _); } private static bool TryGetToolResultInfo(ChatMessage message, out string toolName, out string content) { toolName = ""; content = ""; if (message.Role != "user" || string.IsNullOrWhiteSpace(message.Content) || !message.Content.Contains("\"type\":\"tool_result\"", StringComparison.OrdinalIgnoreCase)) return false; try { using var doc = JsonDocument.Parse(message.Content); if (doc.RootElement.TryGetProperty("tool_name", out var toolNameProp)) { toolName = toolNameProp.GetString() ?? ""; if (doc.RootElement.TryGetProperty("content", out var contentProp)) content = contentProp.GetString() ?? ""; return !string.IsNullOrWhiteSpace(toolName); } } catch { } return false; } private static bool HasCodeVerificationEvidenceAfterLastModification(List messages, bool requireHighImpactEvidence = false) { var modificationTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "file_write", "file_edit", "file_manage", "script_create" }; var readTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "file_read" }; var referenceTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "grep", "glob" }; var delegatedEvidenceTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "wait_agents", "code_search", "lsp" }; var diffTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "git_tool" }; var executionTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "build_run", "test_loop" }; var sawRead = false; var sawReference = false; var sawDelegatedEvidence = false; var sawDiff = false; var sawExecution = false; foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultToolName(message, out var toolName)) continue; if (readTools.Contains(toolName)) sawRead = true; if (referenceTools.Contains(toolName)) sawReference = true; if (delegatedEvidenceTools.Contains(toolName)) sawDelegatedEvidence = true; if (diffTools.Contains(toolName)) sawDiff = true; if (executionTools.Contains(toolName)) sawExecution = true; if (modificationTools.Contains(toolName)) return requireHighImpactEvidence ? (sawReference || sawDelegatedEvidence) && sawExecution && (sawRead || sawDiff) : (sawRead || sawReference || sawDiff || sawExecution); } return true; } private static bool HasAnyBuildOrTestEvidence(List messages) { foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultInfo(message, out var toolName, out var content)) continue; if (toolName is "build_run" or "test_loop" && IsSuccessfulBuildOrTestResult(content)) return true; } return false; } private static bool HasAnyBuildOrTestAttempt(List messages) { foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultToolName(message, out var toolName)) continue; if (toolName is "build_run" or "test_loop") return true; } return false; } private static bool HasBuildOrTestEvidenceAfterLastModification(List messages) { var modificationTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "file_edit", "file_write", "file_manage", "html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create", "chart_create", "document_assemble", "document_plan" }; var sawExecution = false; foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultInfo(message, out var toolName, out var content)) continue; if (toolName is "build_run" or "test_loop") { sawExecution = IsSuccessfulBuildOrTestResult(content); continue; } if (modificationTools.Contains(toolName)) return sawExecution; } return sawExecution; } private static bool HasSuccessfulBuildAndTestAfterLastModification(List messages) { var modificationTools = new HashSet(StringComparer.OrdinalIgnoreCase) { "file_edit", "file_write", "file_manage", "html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create", "chart_create", "document_assemble", "document_plan" }; var sawSuccessfulBuild = false; var sawSuccessfulTest = false; foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultInfo(message, out var toolName, out var content)) continue; if (toolName is "build_run" && IsSuccessfulBuildOrTestResult(content)) { sawSuccessfulBuild = true; continue; } if (toolName is "test_loop" && IsSuccessfulBuildOrTestResult(content)) { sawSuccessfulTest = true; continue; } if (modificationTools.Contains(toolName)) return sawSuccessfulBuild && sawSuccessfulTest; } return sawSuccessfulBuild && sawSuccessfulTest; } private static bool IsSuccessfulBuildOrTestResult(string? content) { if (string.IsNullOrWhiteSpace(content)) return false; if (ContainsAny( content, "fail", "failed", "error", "exception", "timeout", "timed out", "non-zero exit", "exit code 1", "실패", "오류", "예외", "타임아웃")) return false; return ContainsAny( content, "success", "passed", "succeeded", "ok", "통과", "성공", "빌드했습니다", "build succeeded", "tests passed"); } private static bool HasSufficientFinalReportEvidence( string? response, TaskTypePolicy taskPolicy, bool highImpact, List messages) { if (string.IsNullOrWhiteSpace(response)) return false; var hasChangeSummary = ContainsAny(response, "changed", "modified", "변경", "수정", "edit", "diff"); var hasVerification = ContainsAny(response, "build", "test", "검증", "verified", "통과", "실행"); var hasVerificationDetail = ContainsAny( response, "passed", "failed", "success", "error", "exit code", "성공", "실패", "결과", "로그", "output"); var hasRisk = ContainsAny(response, "risk", "concern", "주의", "리스크", "남은", "unknown", "주의점"); var hasScopeOrFile = ContainsAny(response, "file", "path", "caller", "reference", "호출부", "참조", "파일", "영향"); var hasTaskSpecificEvidence = HasTaskSpecificFinalReportEvidence(response, taskPolicy); var hasBuildOrTestEvidence = HasAnyBuildOrTestEvidence(messages); var hasRecentBuildOrTestEvidence = HasBuildOrTestEvidenceAfterLastModification(messages); var hasSuccessfulBuildAndTestEvidence = HasSuccessfulBuildAndTestAfterLastModification(messages); var hasVerificationToolMention = HasExplicitVerificationToolMention(response); var hasDocumentVerificationEvidence = HasDocumentVerificationEvidenceAfterLastArtifact(messages); var hasDocumentVerificationToolMention = HasExplicitDocumentVerificationToolMention(response); var hasDiffEvidence = HasDiffEvidenceAfterLastModification(messages); var hasDiffToolMention = HasExplicitDiffToolMention(response); var pathHints = ExtractRecentFilePathHints(messages); var hasPathEvidence = pathHints.Count == 0 || MentionsAnyPathHint(response, pathHints); var hasExplicitFileMention = HasExplicitFileLikeMention(response); var requiresExplicitFileMention = taskPolicy.TaskType is "bugfix" or "feature" or "refactor" or "review" or "docs"; var isCodeTask = taskPolicy.TaskType is "bugfix" or "feature" or "refactor" or "review"; var hasVerificationSkipReason = HasExplicitVerificationSkipReason(response); var hasRecentFailureSignal = TryGetRecentFailureKind(messages, out _); var hasFailureRecoveryNarrative = HasFailureRecoveryNarrative(response); if (requiresExplicitFileMention && !hasExplicitFileMention) return false; if (requiresExplicitFileMention && hasBuildOrTestEvidence && !hasRecentBuildOrTestEvidence) return false; if (requiresExplicitFileMention && hasBuildOrTestEvidence && !hasVerificationToolMention) return false; if (requiresExplicitFileMention && hasDiffEvidence && !hasDiffToolMention) return false; if (isCodeTask && hasBuildOrTestEvidence && !hasVerificationDetail) return false; if (isCodeTask && !hasBuildOrTestEvidence && !hasVerificationSkipReason) return false; if (isCodeTask && hasRecentFailureSignal && !hasFailureRecoveryNarrative) return false; if (highImpact && !hasSuccessfulBuildAndTestEvidence) return false; if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && !hasDocumentVerificationEvidence) return false; if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && hasDocumentVerificationEvidence && !hasDocumentVerificationToolMention) return false; if (taskPolicy.IsReviewTask) hasChangeSummary = hasChangeSummary || ContainsAny(response, "issue", "finding", "문제", "결함"); if (highImpact) return hasChangeSummary && hasVerification && hasVerificationDetail && hasRisk && hasScopeOrFile && hasPathEvidence && hasTaskSpecificEvidence; if (hasBuildOrTestEvidence) return hasChangeSummary && hasVerification && hasVerificationDetail && (hasScopeOrFile || hasPathEvidence) && hasTaskSpecificEvidence; return hasChangeSummary && hasVerification && (hasScopeOrFile || hasPathEvidence) && hasTaskSpecificEvidence; } private static bool HasSufficientFinalReportEvidence(string? response, string taskType, bool highImpact, List messages) => HasSufficientFinalReportEvidence(response, TaskTypePolicy.FromTaskType(taskType), highImpact, messages); // 테스트/호환용 오버로드: 기존 시그니처를 유지합니다. private static bool HasSufficientFinalReportEvidence(string? response, TaskTypePolicy taskPolicy, bool highImpact) => HasSufficientFinalReportEvidenceLegacy(response, taskPolicy, highImpact); // 테스트/호환용 오버로드: 기존 시그니처를 유지합니다. private static bool HasSufficientFinalReportEvidence(string? response, string taskType, bool highImpact) => HasSufficientFinalReportEvidenceLegacy(response, TaskTypePolicy.FromTaskType(taskType), highImpact); // 테스트 리플렉션 호환 브리지: object 인자를 받아 기존 시그니처로 위임합니다. private static bool HasSufficientFinalReportEvidence(object? response, object taskType, object highImpact) { var text = response?.ToString(); var task = taskType?.ToString() ?? ""; var impact = highImpact is bool b && b; return HasSufficientFinalReportEvidenceLegacy(text, TaskTypePolicy.FromTaskType(task), impact); } private static bool HasSufficientFinalReportEvidenceLegacy( string? text, TaskTypePolicy policy, bool impact) { if (string.IsNullOrWhiteSpace(text)) return false; var hasChangeSummary = ContainsAny(text, "changed", "modified", "변경", "수정", "edit", "diff"); var hasVerification = ContainsAny(text, "build", "test", "검증", "verified", "통과", "실행"); var hasRisk = ContainsAny(text, "risk", "concern", "주의", "리스크", "남은", "unknown", "주의점"); var hasScopeOrFile = ContainsAny(text, "file", "path", "caller", "reference", "호출부", "참조", "파일", "영향"); var hasTaskSpecificEvidence = HasTaskSpecificFinalReportEvidence(text, policy); if (policy.IsReviewTask) hasChangeSummary = hasChangeSummary || ContainsAny(text, "issue", "finding", "문제", "결함"); if (impact) return hasChangeSummary && hasVerification && hasRisk; return hasChangeSummary && hasVerification && hasTaskSpecificEvidence; } private static bool HasExplicitVerificationSkipReason(string response) { return ContainsAny( response, "미실행", "실행하지 못", "실행할 수 없", "환경 제한", "권한 제한", "의존성 부족", "not run", "not executed", "could not run", "unable to run", "permission", "dependency", "missing tool"); } private static bool HasFailureRecoveryNarrative(string response) { var hasFailureMention = ContainsAny( response, "실패", "오류", "원인", "에러", "failed", "error", "failure", "root cause"); var hasActionMention = ContainsAny( response, "수정", "조치", "해결", "우회", "재시도", "retry", "fixed", "mitigation", "workaround"); var hasOutcomeMention = ContainsAny( response, "결과", "통과", "성공", "verified", "passed", "resolved", "재검증"); return hasFailureMention && hasActionMention && hasOutcomeMention; } private static bool TryGetRecentFailureKind(List messages, out FailureRecoveryKind kind) { kind = FailureRecoveryKind.Unknown; foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultInfo(message, out var toolName, out var content)) continue; if (!IsLikelyFailureContentForReport(content)) continue; kind = ClassifyFailureRecoveryKind(toolName, content); return true; } return false; } private static bool IsLikelyFailureContentForReport(string? content) { if (string.IsNullOrWhiteSpace(content)) return false; var lower = content.ToLowerInvariant(); if (ContainsAny(lower, "success", "succeeded", "passed", "통과", "성공", "빌드했습니다", "tests passed", "build succeeded") && !ContainsAny(lower, "fail", "failed", "error", "오류", "실패", "exception", "denied", "not found")) return false; return ContainsAny( lower, "fail", "failed", "error", "exception", "timeout", "timed out", "denied", "forbidden", "not found", "invalid", "실패", "오류", "예외", "시간 초과", "권한", "차단", "찾을 수 없"); } private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact) { var taskLine = taskPolicy.FinalReportTaskLine; var riskLine = highImpact ? "고영향 변경이므로 남은 리스크나 추가 확인 필요 사항도 반드시 적으세요.\n" : ""; return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" + "1. 무엇을 변경했는지\n" + "2. 어떤 파일/호출부를 확인했는지\n" + "3. 어떤 build/test/검증 근거가 있는지 (명령/결과 포함)\n" + "4. 실제 파일 경로 또는 파일명 1~3개를 명시하세요\n" + "5. 실패가 있었다면 원인/조치/재검증 결과를 한 줄 이상 명시하세요\n" + "6. review 작업이면 이슈별로 수정 완료/미수정 상태를 분리해 적으세요\n" + taskLine + riskLine + "가능하면 짧고 명확하게 요약하세요."; } private static string BuildFinalReportQualityPrompt(string taskType, bool highImpact) => BuildFinalReportQualityPrompt(TaskTypePolicy.FromTaskType(taskType), highImpact); private static bool HasTaskSpecificFinalReportEvidence(string response, TaskTypePolicy taskPolicy) { switch (taskPolicy.TaskType) { case "bugfix": { var hasRootCause = ContainsAny(response, "root cause", "원인", "cause"); var hasRepro = ContainsAny(response, "repro", "재현", "재발", "steps to reproduce"); var hasFix = ContainsAny(response, "fix", "fixed", "수정", "해결"); var signals = 0; if (hasRootCause) signals++; if (hasRepro) signals++; if (hasFix) signals++; return signals >= 2; } case "feature": { var hasBehaviorPath = ContainsAny(response, "flow", "path", "동작", "경로", "입력", "출력", "api"); var hasImpactScope = ContainsAny(response, "caller", "reference", "영향", "호출부", "scope"); return hasBehaviorPath && hasImpactScope; } case "refactor": { var hasPreserve = ContainsAny(response, "preserve", "보존", "same behavior", "동작 보존"); var hasRegressionCheck = ContainsAny(response, "regression", "회귀", "no regression", "영향 점검"); return hasPreserve || hasRegressionCheck; } case "review": { var hasIssue = ContainsAny(response, "issue", "finding", "severity", "priority", "risk", "문제", "결함", "취약점"); var hasStatus = ContainsAny( response, "fixed", "not fixed", "unresolved", "resolved", "수정 완료", "미수정", "보류", "해결", "남음"); var hasEvidence = ContainsAny( response, "file", "path", "line", "diff", "build", "test", "로그", "근거", "파일", "라인"); return hasIssue && hasStatus && hasEvidence; } default: return true; } } private static HashSet ExtractRecentFilePathHints(List messages, int maxHints = 8) { var hints = new HashSet(StringComparer.OrdinalIgnoreCase); var allowedExt = new HashSet(StringComparer.OrdinalIgnoreCase) { ".cs", ".xaml", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".sql", ".xml", ".yaml", ".yml", ".py", ".ps1" }; foreach (var message in messages.AsEnumerable().Reverse()) { if (!TryGetToolResultToolName(message, out _)) continue; if (string.IsNullOrWhiteSpace(message.Content)) continue; var tokens = message.Content .Split([' ', '\t', '\r', '\n', '"', '\'', '(', ')', '[', ']', '{', '}', ',', ';'], StringSplitOptions.RemoveEmptyEntries); foreach (var raw in tokens) { var token = raw.Trim(); if (token.Length < 4) continue; var ext = System.IO.Path.GetExtension(token); if (string.IsNullOrWhiteSpace(ext) || !allowedExt.Contains(ext)) continue; var normalized = token.Replace('/', '\\').TrimEnd('.', ':'); var fileName = System.IO.Path.GetFileName(normalized); if (!string.IsNullOrWhiteSpace(fileName)) hints.Add(fileName); if (hints.Count >= maxHints) return hints; } } return hints; } private static bool HasExplicitFileLikeMention(string response) { if (string.IsNullOrWhiteSpace(response)) return false; var matches = System.Text.RegularExpressions.Regex.Matches( response, @"(?i)\b[\w\-./\\]+\.(cs|xaml|ts|tsx|js|jsx|json|md|txt|sql|xml|yaml|yml|py|ps1|html|css)\b"); return matches.Count > 0; } private static bool HasExplicitVerificationToolMention(string response) { if (string.IsNullOrWhiteSpace(response)) return false; return ContainsAny( response, "build_run", "test_loop", "dotnet build", "dotnet test", "npm test", "pnpm test", "yarn test", "pytest", "mvn test", "gradle test"); } private static bool HasExplicitDocumentVerificationToolMention(string response) { if (string.IsNullOrWhiteSpace(response)) return false; return ContainsAny( response, "file_read", "document_read", "document_review", "검증", "검토", "확인"); } private static bool HasExplicitDiffToolMention(string response) { if (string.IsNullOrWhiteSpace(response)) return false; return ContainsAny( response, "git_tool", "git diff", "diff", "changed files", "변경점", "diff 근거"); } private static string BuildVerificationReport( string fileRef, string? filePath, bool isCodeTab, string response, bool hasIssues, int verificationToolCallCount) { var status = hasIssues ? "FAIL" : "PASS"; var taskKind = isCodeTab ? "code" : "document"; var target = string.IsNullOrWhiteSpace(filePath) ? fileRef : filePath; var highlights = ExtractVerificationHighlights(response, hasIssues ? 5 : 3); var highlightText = highlights.Count == 0 ? "- 핵심 근거 추출 실패" : string.Join("\n", highlights.Select(x => $"- {x}")); return "[VerificationReport]\n" + $"- Status: {status}\n" + $"- Type: {taskKind}\n" + $"- Target: {target}\n" + $"- ReadTools: {verificationToolCallCount}\n" + "- Highlights:\n" + highlightText; } private static List ExtractVerificationHighlights(string response, int maxCount) { if (string.IsNullOrWhiteSpace(response)) return []; var lines = response .Replace("\r\n", "\n", StringComparison.Ordinal) .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(x => x.TrimStart('-', '*', '•', ' ')) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList(); if (lines.Count == 0) return []; var issueKeywords = new[] { "오류", "실패", "누락", "불일치", "문제", "경고", "risk", "error", "failed", "invalid", "missing" }; var passKeywords = new[] { "통과", "문제 없음", "정상", "ok", "pass", "verified", "완료" }; return lines .Select(line => { var lower = line.ToLowerInvariant(); var issueScore = issueKeywords.Count(k => lower.Contains(k.ToLowerInvariant(), StringComparison.Ordinal)); var passScore = passKeywords.Count(k => lower.Contains(k.ToLowerInvariant(), StringComparison.Ordinal)); return (line, score: issueScore * 2 + passScore); }) .OrderByDescending(x => x.score) .ThenByDescending(x => x.line.Length) .Take(Math.Max(1, maxCount)) .Select(x => x.line) .ToList(); } private static string BuildIterationLimitFallbackResponse( int maxIterations, TaskTypePolicy taskPolicy, int totalToolCalls, int successToolCalls, int failedToolCalls, string? lastArtifactFilePath, List usedTools, Dictionary failedToolHistogram, List messages) { var pathHints = ExtractRecentFilePathHints(messages, 3).Take(3).ToList(); var artifactLine = !string.IsNullOrWhiteSpace(lastArtifactFilePath) ? $"- 산출물 파일: {lastArtifactFilePath}" : pathHints.Count > 0 ? $"- 최근 파일 근거: {string.Join(", ", pathHints)}" : "- 파일 근거: 추출되지 않음"; var verificationLine = HasAnyBuildOrTestEvidence(messages) ? "- 검증 근거: build/test 실행 이력 있음" : "- 검증 근거: build/test 실행 이력 부족"; var toolLine = usedTools.Count > 0 ? $"- 사용 도구: {string.Join(", ", usedTools.Take(8))}" : "- 사용 도구: 없음"; var failureTopLine = $"- 실패 상위 도구: {BuildTopFailureSummary(failedToolHistogram)}"; var nextAction = taskPolicy.TaskType switch { "docs" => "다음 실행에서는 html_create/markdown_create 같은 생성 도구를 먼저 호출해 산출물을 확정하세요.", "bugfix" => "다음 실행에서는 재현 조건 → 원인 → 수정 → build/test 순서로 고정해 진행하세요.", _ => "다음 실행에서는 동일 읽기 반복을 줄이고 수정/검증 도구 실행으로 바로 전환하세요." }; return "⚠ 에이전트가 최대 반복 횟수에 도달했습니다.\n" + $"- 반복 한도: {maxIterations}\n" + $"- 작업 유형: {taskPolicy.TaskType}\n" + $"- 도구 호출: 총 {totalToolCalls}회 (성공 {successToolCalls}, 실패 {failedToolCalls})\n" + artifactLine + "\n" + verificationLine + "\n" + toolLine + "\n" + failureTopLine + "\n" + $"- 다음 권장 액션: {nextAction}"; } private static string BuildTopFailureSummary(Dictionary failedToolHistogram) { if (failedToolHistogram == null || failedToolHistogram.Count == 0) return "없음"; return string.Join(", ", failedToolHistogram .OrderByDescending(x => x.Value) .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Take(3) .Select(x => $"{x.Key}({x.Value})")); } private static int GetNoToolCallResponseThreshold() => ResolveNoToolCallResponseThreshold( Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD")); private static int GetNoToolCallRecoveryMaxRetries() => ResolveNoToolCallRecoveryMaxRetries( Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES")); private static int GetPlanExecutionRetryMax() => ResolvePlanExecutionRetryMax( Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX")); private static int ResolveNoToolCallResponseThreshold(string? envRaw) => ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6); private static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw) => ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6); private static int ResolvePlanExecutionRetryMax(string? envRaw) => ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6); private static int GetTerminalEvidenceGateMaxRetries() => ResolveTerminalEvidenceGateMaxRetries( Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES")); private static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw) => ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3); private static string BuildUnknownToolRecoveryPrompt( string unknownToolName, IReadOnlyCollection activeToolNames) { var normalizedUnknown = NormalizeAliasToken(unknownToolName); var aliasHint = ToolAliasMap.TryGetValue(normalizedUnknown, out var mappedCandidate) && activeToolNames.Any(name => string.Equals(name, mappedCandidate, StringComparison.OrdinalIgnoreCase)) ? $"- 자동 매핑 후보: {unknownToolName} → {mappedCandidate}\n" : ""; var suggestions = activeToolNames .Where(name => name.Contains(unknownToolName, StringComparison.OrdinalIgnoreCase) || unknownToolName.Contains(name, StringComparison.OrdinalIgnoreCase)) .Take(5) .ToList(); if (suggestions.Count == 0) suggestions = activeToolNames.Take(8).ToList(); return "[System:UnknownToolRecovery] 방금 호출한 도구는 등록되어 있지 않습니다.\n" + $"- 실패 도구: {unknownToolName}\n" + aliasHint + $"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" + "- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" + "위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요."; } private static string BuildDisallowedToolRecoveryPrompt( string requestedToolName, IReadOnlySet? policyAllowedToolNames, IReadOnlyCollection activeToolNames) { var policyLine = policyAllowedToolNames != null && policyAllowedToolNames.Count > 0 ? $"- 스킬 정책 허용 도구: {string.Join(", ", policyAllowedToolNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).Take(12))}\n" : ""; var activePreview = activeToolNames.Count > 0 ? string.Join(", ", activeToolNames.Take(8)) : "(없음)"; return "[System:DisallowedToolRecovery] 방금 호출한 도구는 현재 스킬/런타임 정책에서 허용되지 않습니다.\n" + $"- 요청 도구: {requestedToolName}\n" + policyLine + $"- 지금 사용 가능한 도구 예시: {activePreview}\n" + "- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" + "허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다."; } private static string BuildUnknownToolLoopAbortResponse( string unknownToolName, int repeatedUnknownToolCount, IReadOnlyCollection activeToolNames) { var preview = string.Join(", ", activeToolNames.Take(10)); return "⚠ 동일한 미등록 도구 호출이 반복되어 작업을 중단했습니다.\n" + $"- 반복 횟수: {repeatedUnknownToolCount}\n" + $"- 실패 도구: {unknownToolName}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" + "- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요."; } private static string BuildDisallowedToolLoopAbortResponse( string toolName, int repeatedCount, IReadOnlyCollection activeToolNames) { var preview = string.Join(", ", activeToolNames.Take(10)); return "⚠ 동일한 비허용 도구 호출이 반복되어 작업을 중단했습니다.\n" + $"- 반복 횟수: {repeatedCount}\n" + $"- 비허용 도구: {toolName}\n" + $"- 현재 사용 가능한 도구 예시: {preview}\n" + "- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요."; } private static readonly Dictionary ToolAliasMap = new(StringComparer.OrdinalIgnoreCase) { ["read"] = "file_read", ["readfile"] = "file_read", ["read_file"] = "file_read", ["write"] = "file_write", ["writefile"] = "file_write", ["write_file"] = "file_write", ["edit"] = "file_edit", ["editfile"] = "file_edit", ["edit_file"] = "file_edit", ["bash"] = "process", ["shell"] = "process", ["terminal"] = "process", ["run"] = "process", ["ls"] = "glob", ["listfiles"] = "glob", ["list_files"] = "glob", ["grep"] = "grep", ["greptool"] = "grep", ["grep_tool"] = "grep", ["rg"] = "grep", ["ripgrep"] = "grep", ["search"] = "grep", ["globfiles"] = "glob", ["glob_files"] = "glob", // claw-code 계열 도구명 호환 ["webfetch"] = "http_tool", ["websearch"] = "http_tool", ["askuserquestion"] = "user_ask", ["lsp"] = "lsp_code_intel", ["listmcpresourcestool"] = "mcp_list_resources", ["readmcpresourcetool"] = "mcp_read_resource", ["agent"] = "spawn_agent", ["spawnagent"] = "spawn_agent", ["task"] = "spawn_agent", ["sendmessage"] = "notify_tool", ["shellcommand"] = "process", ["execute"] = "process", ["codesearch"] = "search_codebase", ["code_search"] = "search_codebase", ["powershell"] = "process", ["toolsearch"] = "tool_search", ["todowrite"] = "todo_write", ["taskcreate"] = "task_create", ["taskget"] = "task_get", ["tasklist"] = "task_list", ["taskupdate"] = "task_update", ["taskstop"] = "task_stop", ["taskoutput"] = "task_output", ["enterplanmode"] = "enter_plan_mode", ["exitplanmode"] = "exit_plan_mode", ["enterworktree"] = "enter_worktree", ["exitworktree"] = "exit_worktree", ["teamcreate"] = "team_create", ["teamdelete"] = "team_delete", ["croncreate"] = "cron_create", ["crondelete"] = "cron_delete", ["cronlist"] = "cron_list", ["config"] = "project_rules", ["skill"] = "skill_manager", }; private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection activeToolNames) { var requested = requestedToolName.Trim(); if (string.IsNullOrWhiteSpace(requested)) return requested; var direct = activeToolNames.FirstOrDefault(name => string.Equals(name, requested, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(direct)) return direct; var normalizedRequested = NormalizeAliasToken(requested); if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped)) { var mappedDirect = activeToolNames.FirstOrDefault(name => string.Equals(name, mapped, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(mappedDirect)) return mappedDirect; } var normalizedMatch = activeToolNames.FirstOrDefault(name => string.Equals(NormalizeAliasToken(name), normalizedRequested, StringComparison.Ordinal)); if (!string.IsNullOrWhiteSpace(normalizedMatch)) return normalizedMatch; return requested; } private static string NormalizeAliasToken(string value) { if (string.IsNullOrWhiteSpace(value)) return ""; Span buffer = stackalloc char[value.Length]; var idx = 0; foreach (var ch in value) { if (ch is '_' or '-' or ' ') continue; buffer[idx++] = char.ToLowerInvariant(ch); } return new string(buffer[..idx]); } private static string BuildNoProgressAbortResponse( TaskTypePolicy taskPolicy, int consecutiveNonMutatingSuccessTools, int recoveryRetryCount, string? lastArtifactFilePath, List usedTools) { var artifactLine = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "산출물 파일 확인 불가" : lastArtifactFilePath; var toolPreview = usedTools.Count > 0 ? string.Join(", ", usedTools.Take(10)) : "없음"; var nextAction = taskPolicy.TaskType switch { "docs" => "다음 실행은 생성 도구(html_create/markdown_create)부터 시작하세요.", "bugfix" => "다음 실행은 재현-원인-수정-build/test 순서를 강제하세요.", _ => "다음 실행은 읽기 반복을 줄이고 수정/실행 도구를 우선 호출하세요." }; return "⚠ 비진행 상태가 지속되어 작업을 중단했습니다.\n" + $"- 작업 유형: {taskPolicy.TaskType}\n" + $"- 연속 비진행 호출: {consecutiveNonMutatingSuccessTools}\n" + $"- 복구 시도 횟수: {recoveryRetryCount}\n" + $"- 최근 산출물: {artifactLine}\n" + $"- 사용 도구: {toolPreview}\n" + $"- 다음 권장 액션: {nextAction}"; } private static bool MentionsAnyPathHint(string response, HashSet hints) { foreach (var hint in hints) { if (response.Contains(hint, StringComparison.OrdinalIgnoreCase)) return true; } return false; } private void RememberFailurePattern( string toolName, ToolResult result, AgentContext context, TaskTypePolicy taskPolicy, string? lastModifiedCodeFilePath, int consecutiveErrors, int maxRetry) { if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) return; if (toolName is not "build_run" and not "test_loop") return; try { var app = System.Windows.Application.Current as App; var memSvc = app?.MemoryService; if (memSvc == null || !(app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false)) return; var content = BuildFailureMemoryContent( toolName, result, taskPolicy, lastModifiedCodeFilePath, consecutiveErrors, maxRetry); memSvc.Add("correction", content, $"conv:{_conversationId}", context.WorkFolder); } catch { } } private static string BuildFailureMemoryContent( string toolName, ToolResult result, TaskTypePolicy taskPolicy, string? lastModifiedCodeFilePath, int consecutiveErrors, int maxRetry) { var filePart = string.IsNullOrWhiteSpace(lastModifiedCodeFilePath) ? "" : $" | file:{NormalizeFailurePatternValue(lastModifiedCodeFilePath)}"; var impactPart = IsHighImpactCodePath(lastModifiedCodeFilePath) ? " | impact:high" : ""; var failureKind = ClassifyFailureRecoveryKind(toolName, result.Output).ToString().ToLowerInvariant(); var kindPart = string.IsNullOrWhiteSpace(failureKind) ? "" : $" | kind:{NormalizeFailurePatternValue(failureKind)}"; var safeTool = NormalizeFailurePatternValue(toolName); var safeError = NormalizeFailurePatternValue(TruncateOutput(result.Output, 240)); return $"code-failure | task:{taskPolicy.TaskType}{kindPart} | tool:{safeTool}{filePart}{impactPart} | retry:{consecutiveErrors}/{maxRetry} | error:{safeError}"; } private static string BuildFailureMemoryContent( string toolName, ToolResult result, string taskType, string? lastModifiedCodeFilePath, int consecutiveErrors, int maxRetry) => BuildFailureMemoryContent( toolName, result, TaskTypePolicy.FromTaskType(taskType), lastModifiedCodeFilePath, consecutiveErrors, maxRetry); private static string NormalizeFailurePatternValue(string? value) { if (string.IsNullOrWhiteSpace(value)) return ""; return value .Replace("|", "/", StringComparison.Ordinal) .Replace("\r", " ", StringComparison.Ordinal) .Replace("\n", " ", StringComparison.Ordinal) .Trim(); } private async Task RunPostToolVerificationAsync( List messages, string toolName, ToolResult result, AgentContext context, CancellationToken ct) { EmitEvent(AgentEventType.Thinking, "", "🔍 생성 결과물 검증 중..."); // 생성된 파일 경로 추출 var filePath = result.FilePath ?? ""; var fileRef = string.IsNullOrEmpty(filePath) ? "방금 생성한 결과물" : $"파일 '{filePath}'"; // 탭별 검증 프롬프트 생성 — 읽기 + 보고만 (수정 금지) var isCodeTab = ActiveTab == "Code"; var checkList = isCodeTab ? " - 구문 오류가 없는가?\n" + " - 참조하는 클래스/메서드/변수가 존재하는가?\n" + " - 코딩 컨벤션이 일관적인가?\n" + " - 에지 케이스 처리가 누락되지 않았는가?\n" + " - 수정한 코드의 호출부/영향 범위를 다시 확인했는가?\n" + " - 방금 실행한 빌드/테스트 결과와 충돌하는 내용이 없는가?" : " - 사용자 요청에 맞는 내용이 모두 포함되었는가?\n" + " - 구조와 형식이 올바른가?\n" + " - 누락된 섹션이나 불완전한 내용이 없는가?\n" + " - 한국어 맞춤법/표현이 자연스러운가?"; var verificationPrompt = new ChatMessage { Role = "user", Content = $"[System:Verification] {fileRef}을 검증하세요.\n" + "1. file_read 도구로 생성된 파일의 내용을 읽으세요.\n" + "2. 다음 항목을 확인하세요:\n" + checkList + "\n" + "3. 결과를 간단히 보고하세요. 문제가 있으면 구체적으로 무엇이 잘못되었는지 설명하세요.\n" + "4. 문제가 없으면 반드시 '검증 통과' 또는 '문제 없음'처럼 명시적으로 적으세요.\n" + "⚠️ 중요: 이 단계에서는 파일을 직접 수정하지 마세요. 보고만 하세요." }; // 검증 메시지를 임시로 추가 (검증 완료 후 전부 제거) var insertIndex = messages.Count; messages.Add(verificationPrompt); var addedMessages = new List { verificationPrompt }; var verificationToolCallCount = 0; try { // 읽기 전용 도구만 제공 (file_write, file_edit 등 쓰기 도구 차단) var allTools = _tools.GetActiveTools(_settings.Settings.Llm.DisabledTools); var readOnlyTools = allTools .Where(t => VerificationAllowedTools.Contains(t.Name)) .ToList(); var verifyBlocks = await SendWithToolsWithRecoveryAsync( messages, readOnlyTools, ct, "검증 1차"); // 검증 응답 처리 var verifyText = new List(); var verifyToolCalls = new List(); foreach (var block in verifyBlocks) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) verifyText.Add(block.Text); else if (block.Type == "tool_use") verifyToolCalls.Add(block); } var verifyResponse = string.Join("\n", verifyText); // file_read 도구 호출 처리 (읽기만 허용) if (verifyToolCalls.Count > 0) { var contentBlocks = new List(); if (!string.IsNullOrEmpty(verifyResponse)) contentBlocks.Add(new { type = "text", text = verifyResponse }); foreach (var tc in verifyToolCalls) contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput }); var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }); var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent }; messages.Add(assistantMsg); addedMessages.Add(assistantMsg); foreach (var tc in verifyToolCalls) { var tool = _tools.Get(tc.ToolName); if (tool == null) { var errMsg = LlmService.CreateToolResultMessage(tc.ToolId, tc.ToolName, "검증 단계에서는 읽기 도구만 사용 가능합니다."); messages.Add(errMsg); addedMessages.Add(errMsg); continue; } EmitEvent(AgentEventType.ToolCall, tc.ToolName, $"[검증] {FormatToolCallSummary(tc)}"); verificationToolCallCount++; try { var input = tc.ToolInput ?? System.Text.Json.JsonDocument.Parse("{}").RootElement; var verifyResult = await ExecuteToolWithTimeoutAsync(tool, tc.ToolName, input, context, messages, ct); var toolMsg = LlmService.CreateToolResultMessage( tc.ToolId, tc.ToolName, TruncateOutput(verifyResult.Output, 4000)); messages.Add(toolMsg); addedMessages.Add(toolMsg); } catch (Exception ex) { var errMsg = LlmService.CreateToolResultMessage( tc.ToolId, tc.ToolName, $"검증 도구 실행 오류: {ex.Message}"); messages.Add(errMsg); addedMessages.Add(errMsg); } } // file_read 결과를 받은 후 최종 검증 판단을 받기 위해 한 번 더 호출 var finalBlocks = await SendWithToolsWithRecoveryAsync( messages, readOnlyTools, ct, "검증 최종"); verifyResponse = string.Join("\n", finalBlocks.Where(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text)).Select(b => b.Text)); } // 검증 결과를 이벤트로 표시 if (!string.IsNullOrEmpty(verifyResponse)) { // 문제가 발견된 경우: 검증 보고서를 컨텍스트에 남겨서 다음 루프에서 자연스럽게 수정 var hasIssues = IsVerificationIssueDetected(verifyResponse, isCodeTab); var report = BuildVerificationReport( fileRef, filePath, isCodeTab, verifyResponse, hasIssues, verificationToolCallCount); var brief = hasIssues ? $"❌ 검증 실패 ({Path.GetFileName(filePath)})" : $"✅ 검증 통과 ({Path.GetFileName(filePath)})"; EmitEvent(AgentEventType.StepDone, "verification", brief); messages.Add(new ChatMessage { Role = "assistant", Content = report }); if (hasIssues) { // 검증 관련 임시 메시지를 모두 제거 foreach (var msg in addedMessages) messages.Remove(msg); // 검증 보고서만 간결하게 남기기 (다음 루프에서 LLM이 자연스럽게 수정) messages.Add(new ChatMessage { Role = "user", Content = $"[System] 방금 생성한 {fileRef}에 대한 자동 검증에서 문제가 발견되었습니다.\n\n{report}\n\n원본 검증 응답:\n{verifyResponse}\n\n위 문제를 수정해 주세요." }); return; } } } catch (Exception ex) { EmitEvent(AgentEventType.Error, "", $"검증 LLM 호출 실패: {ex.Message}"); messages.Add(new ChatMessage { Role = "assistant", Content = BuildVerificationReport( fileRef, filePath, isCodeTab, $"검증 실행 중 오류: {ex.Message}", hasIssues: true, verificationToolCallCount) }); } // 검증 통과 또는 실패: 임시 메시지 전부 제거 (컨텍스트 오염 방지) foreach (var msg in addedMessages) messages.Remove(msg); } private AgentContext BuildContext() { var llm = _settings.Settings.Llm; var baseWorkFolder = llm.WorkFolder; var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder); return new AgentContext { WorkFolder = runtimeWorkFolder, Permission = llm.FilePermission, BlockedPaths = llm.BlockedPaths, BlockedExtensions = llm.BlockedExtensions, AskPermission = AskPermissionCallback, UserDecision = UserDecisionCallback, UserAskCallback = UserAskCallback, ToolPermissions = new Dictionary(llm.ToolPermissions ?? new(), StringComparer.OrdinalIgnoreCase), ActiveTab = ActiveTab, OperationMode = _settings.Settings.OperationMode, DevMode = llm.DevMode, DevModeStepApproval = llm.DevModeStepApproval, }; } private static string ResolveRuntimeWorkFolder(string? configuredRoot) { if (string.IsNullOrWhiteSpace(configuredRoot)) return ""; try { var root = Path.GetFullPath(configuredRoot); if (!Directory.Exists(root)) return root; var state = WorktreeStateStore.Load(root); if (!string.IsNullOrWhiteSpace(state.Active)) { var active = Path.GetFullPath(state.Active); if (Directory.Exists(active) && active.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return active; } return root; } catch { return configuredRoot ?? ""; } } private static string DescribeToolTarget(string toolName, JsonElement input, AgentContext context) { static string? TryReadString(JsonElement inputElement, params string[] names) { foreach (var name in names) { if (inputElement.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) { var value = prop.GetString(); if (!string.IsNullOrWhiteSpace(value)) return value; } } return null; } var primary = TryReadString(input, "path", "filePath", "destination", "url", "command", "project_path", "cwd"); if (string.IsNullOrWhiteSpace(primary)) return toolName; if ((toolName is "file_write" or "file_edit" or "file_manage" or "open_external" or "html_create" or "markdown_create" or "docx_create" or "excel_create" or "csv_create" or "pptx_create" or "chart_create" or "script_create") && !Path.IsPathRooted(primary) && !string.IsNullOrWhiteSpace(context.WorkFolder)) { try { return Path.GetFullPath(Path.Combine(context.WorkFolder, primary)); } catch { return primary; } } return primary; } private static string BuildPermissionPreviewKey(string toolName, string target) { if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) return ""; return $"{toolName.Trim()}|{target.Trim()}"; } private static PermissionPromptPreview? BuildPermissionPreview(string toolName, JsonElement input, string target) { var normalizedTool = toolName.Trim().ToLowerInvariant(); if (normalizedTool.Contains("file_write")) { var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? "" : ""; var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)"; string? previous = null; try { if (File.Exists(target)) { var original = File.ReadAllText(target); previous = original.Length <= 2000 ? original : original[..2000] + "\n... (truncated)"; } } catch { previous = null; } return new PermissionPromptPreview( Kind: "file_write", Title: "Pending file write", Summary: "The tool will replace or create file content.", Content: truncated, PreviousContent: previous); } if (normalizedTool.Contains("file_edit")) { if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array) { var lines = edits.EnumerateArray() .Take(6) .Select((edit, index) => { var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String ? oldElem.GetString() ?? "" : ""; var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String ? newElem.GetString() ?? "" : ""; oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "..."; newText = newText.Length <= 180 ? newText : newText[..180] + "..."; return $"{index + 1}) - {oldText}\n + {newText}"; }); return new PermissionPromptPreview( Kind: "file_edit", Title: "Pending file edit", Summary: $"The tool requested {edits.GetArrayLength()} edit block(s).", Content: string.Join("\n", lines)); } } if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell")) { var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String ? cmd.GetString() ?? target : target; return new PermissionPromptPreview( Kind: "command", Title: "Pending command", Summary: "The tool wants to run this command.", Content: command); } if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http")) { var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String ? u.GetString() ?? target : target; return new PermissionPromptPreview( Kind: "web", Title: "Pending network access", Summary: "The tool wants to access this URL.", Content: url); } return null; } private static bool TryGetHookUpdatedInput( IEnumerable results, out JsonElement updatedInput) { updatedInput = default; var hasValue = false; foreach (var result in results) { if (!result.Success || !result.UpdatedInput.HasValue) continue; updatedInput = result.UpdatedInput.Value; hasValue = true; } return hasValue; } private static void ApplyHookPermissionUpdates( AgentContext context, HookExecutionResult result, bool enabled) { if (!enabled || !result.Success || result.UpdatedPermissions == null || result.UpdatedPermissions.Count == 0) return; foreach (var kv in result.UpdatedPermissions) { if (!TryNormalizePermissionValue(kv.Value, out var normalized)) continue; var key = NormalizeHookPermissionTarget(kv.Key); if (string.IsNullOrWhiteSpace(key)) continue; context.ToolPermissions[key] = normalized; } } private static void ApplyHookAdditionalContext(List messages, HookExecutionResult result) { if (!result.Success || string.IsNullOrWhiteSpace(result.AdditionalContext)) return; messages.Add(new ChatMessage { Role = "user", Content = $"[Hook Additional Context]\n{TruncateOutput(result.AdditionalContext.Trim(), 2000)}" }); } private static bool TryNormalizePermissionValue(string? value, out string normalized) { normalized = ""; if (string.IsNullOrWhiteSpace(value)) return false; switch (value.Trim().ToLowerInvariant()) { case "ask": normalized = "ask"; return true; case "plan": normalized = "ask"; return true; case "auto": normalized = "auto"; return true; case "deny": normalized = "deny"; return true; default: return false; } } private static string NormalizeHookPermissionTarget(string? rawKey) { if (string.IsNullOrWhiteSpace(rawKey)) return ""; var trimmed = rawKey.Trim(); if (trimmed is "*" or "default") return trimmed; var normalizedRequested = NormalizeAliasToken(trimmed); if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped)) return mapped; return trimmed; } private async Task RunPermissionLifecycleHooksAsync( string hookToolName, string timing, string payload, AgentContext context, List? messages, bool success = true) { var llm = _settings.Settings.Llm; if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return; try { var hookResults = await AgentHookRunner.RunAsync( llm.AgentHooks, hookToolName, timing, toolInput: payload, success: success, workFolder: context.WorkFolder, timeoutMs: llm.ToolHookTimeoutMs); foreach (var pr in hookResults) { EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success); if (messages != null) ApplyHookAdditionalContext(messages, pr); ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate); } } catch { // permission lifecycle hook failure must not block tool permission flow } } private async Task EnforceToolPermissionAsync( string toolName, JsonElement input, AgentContext context, List? messages = null) { var target = DescribeToolTarget(toolName, input, context); var requestPayload = JsonSerializer.Serialize(new { runId = _currentRunId, tool = toolName, target, permission = context.GetEffectiveToolPermission(toolName, target) }); await RunPermissionLifecycleHooksAsync( "__permission_request__", "pre", requestPayload, context, messages, success: true); var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.GetEffectiveToolPermission(toolName, target)); if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요({effectivePerm}) · 대상: {target}"); var previewKey = BuildPermissionPreviewKey(toolName, target); var preview = BuildPermissionPreview(toolName, input, target); if (!string.IsNullOrWhiteSpace(previewKey) && preview != null) _pendingPermissionPreviews[previewKey] = preview; bool allowed; try { allowed = await context.CheckToolPermissionAsync(toolName, target); } finally { if (!string.IsNullOrWhiteSpace(previewKey)) _pendingPermissionPreviews.TryRemove(previewKey, out _); } if (allowed) { if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) EmitEvent(AgentEventType.PermissionGranted, toolName, $"권한 승인됨({effectivePerm}) · 대상: {target}"); await RunPermissionLifecycleHooksAsync( "__permission_granted__", "post", JsonSerializer.Serialize(new { runId = _currentRunId, tool = toolName, target, permission = effectivePerm, granted = true }), context, messages, success: true); return null; } var reason = string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase) ? $"도구 권한이 Deny로 설정되어 차단되었습니다: {toolName}" : $"사용자 승인 없이 실행할 수 없는 도구입니다: {toolName}"; EmitEvent(AgentEventType.PermissionDenied, toolName, $"{reason}\n대상: {target}"); await RunPermissionLifecycleHooksAsync( "__permission_denied__", "post", JsonSerializer.Serialize(new { runId = _currentRunId, tool = toolName, target, permission = effectivePerm, granted = false, reason }), context, messages, success: false); return ToolResult.Fail($"{reason}\n대상: {target}"); } private void EmitEvent(AgentEventType type, string toolName, string summary, string? filePath = null, int stepCurrent = 0, int stepTotal = 0, List? steps = null, long elapsedMs = 0, int inputTokens = 0, int outputTokens = 0, string? toolInput = null, int iteration = 0, bool? successOverride = null) { // AgentLogLevel에 따라 이벤트 필터링 var logLevel = _settings.Settings.Llm.AgentLogLevel; // simple: ToolCall, ToolResult, Error, Complete, StepStart, StepDone, Decision만 if (logLevel == "simple" && type is AgentEventType.Thinking or AgentEventType.Planning) return; // simple: Summary 200자 제한 if (logLevel == "simple" && summary.Length > 200) summary = summary[..200] + "…"; // debug 아닌 경우 ToolInput 제거 if (logLevel != "debug") toolInput = null; var evt = new AgentEvent { RunId = _currentRunId, Type = type, ToolName = toolName, Summary = summary, FilePath = filePath, Success = successOverride ?? (type is not AgentEventType.Error and not AgentEventType.PermissionDenied), StepCurrent = stepCurrent, StepTotal = stepTotal, Steps = steps, ElapsedMs = elapsedMs, InputTokens = inputTokens, OutputTokens = outputTokens, ToolInput = toolInput, Iteration = iteration, }; if (Dispatcher != null) Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); }); else { Events.Add(evt); EventOccurred?.Invoke(evt); } } /// 영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null. private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) { var app = System.Windows.Application.Current as App; var level = app?.SettingsService?.Settings.Llm.AgentDecisionLevel ?? "normal"; var toolName = call.ToolName ?? ""; var input = call.ToolInput; // Git 커밋 — 수준에 관계없이 무조건 확인 if (toolName == "git_tool") { var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : ""; if (action == "commit") { var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : ""; return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}"; } } // minimal: 파일 삭제, 외부 명령만 if (level == "minimal") { // process 도구 (외부 명령 실행) if (toolName == "process") { var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : ""; return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}"; } return null; } // normal: + 새 파일 생성, 여러 파일 수정, 문서 생성, 외부 명령 if (level == "normal" || level == "detailed") { // 외부 명령 실행 if (toolName == "process") { var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : ""; return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}"; } // 새 파일 생성 if (toolName == "file_write") { var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; if (!string.IsNullOrEmpty(path)) { var fullPath = System.IO.Path.IsPathRooted(path) ? path : System.IO.Path.Combine(context.WorkFolder, path ?? ""); if (!System.IO.File.Exists(fullPath)) return $"새 파일을 생성하시겠습니까?\n\n경로: {path}"; } } // 문서 생성 (Excel, Word, HTML 등) if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create") { var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}"; } // 빌드/테스트 실행 if (toolName is "build_run" or "test_loop") { var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : ""; return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}"; } } // detailed: 모든 파일 수정 if (level == "detailed") { if (toolName is "file_write" or "file_edit") { var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : ""; return $"파일을 수정하시겠습니까?\n\n경로: {path}"; } } return null; } private static string FormatToolCallSummary(LlmService.ContentBlock call) { if (call.ToolInput == null) return call.ToolName; try { // 주요 파라미터만 표시 var input = call.ToolInput.Value; if (input.TryGetProperty("path", out var path)) return $"{call.ToolName}: {path.GetString()}"; if (input.TryGetProperty("command", out var cmd)) return $"{call.ToolName}: {cmd.GetString()}"; if (input.TryGetProperty("pattern", out var pat)) return $"{call.ToolName}: {pat.GetString()}"; return call.ToolName; } catch { return call.ToolName; } } private static string BuildToolCallSignature(LlmService.ContentBlock call) { var name = (call.ToolName ?? "").Trim().ToLowerInvariant(); if (call.ToolInput == null) return name; try { var canonical = JsonSerializer.Serialize(call.ToolInput.Value); if (canonical.Length > 512) canonical = canonical[..512]; return $"{name}|{canonical}"; } catch { return name; } } private static bool ShouldBlockRepeatedFailedCall( string currentSignature, string? lastFailedSignature, int repeatedFailedCount, int maxRetry) { if (string.IsNullOrWhiteSpace(currentSignature) || string.IsNullOrWhiteSpace(lastFailedSignature)) return false; if (!string.Equals(currentSignature, lastFailedSignature, StringComparison.Ordinal)) return false; var threshold = Math.Max(2, Math.Min(maxRetry, 3)); return repeatedFailedCount >= threshold; } private static string BuildRepeatedFailureGuardMessage(string toolName, int repeatedFailedCount, int maxRetry) { return $"[RepeatedFailureGuard] '{toolName}' 도구를 동일 파라미터로 {repeatedFailedCount}회 이상 실패했습니다. " + $"같은 호출을 반복하지 말고 원인 분석 후 다른 접근으로 전환하세요. (maxRetry={maxRetry})"; } private static string BuildRepeatedFailureRecoveryPrompt( string toolName, string? lastModifiedCodeFilePath, bool highImpactChange, TaskTypePolicy taskPolicy) { var diversification = BuildExecutionRetryDiversificationPrompt(toolName); return "[System:RetryStrategy] 동일 도구/파라미터 반복 실패를 감지했습니다. 같은 호출을 반복하지 마세요.\n" + BuildFailureInvestigationPrompt(toolName, lastModifiedCodeFilePath, highImpactChange, taskPolicy) + "\n" + diversification + BuildFailureNextToolPriorityPrompt(toolName, lastModifiedCodeFilePath, highImpactChange, taskPolicy); } private static string BuildExecutionRetryDiversificationPrompt(string toolName) { if (toolName is not "build_run" and not "test_loop") return ""; return "[System:RetryDiversification] build/test 재시도 규칙:\n" + "1. 직전 실패 로그에서 핵심 오류 1~2줄을 먼저 인용하세요.\n" + "2. 다음 재시도는 이전과 최소 1개 축을 다르게 실행하세요 (대상 프로젝트/테스트 필터/설정/작업 디렉터리).\n" + "3. 코드 변경 없이 동일 명령 재실행은 금지합니다.\n" + "4. 재시도 전 file_read/grep/git_tool(diff) 중 최소 2개 근거를 확보하세요.\n"; } private static string BuildFailureNextToolPriorityPrompt( string failedToolName, string? lastModifiedCodeFilePath, bool highImpactChange, TaskTypePolicy taskPolicy, string? toolOutput = null) { var fileHint = string.IsNullOrWhiteSpace(lastModifiedCodeFilePath) ? "최근 수정 파일" : $"'{lastModifiedCodeFilePath}'"; var failureKind = ClassifyFailureRecoveryKind(failedToolName, toolOutput); var priority = ResolveFailureKindPrioritySequence(failedToolName, failureKind); var kindLabel = DescribeFailureKindToken(failureKind.ToString().ToLowerInvariant()); var highImpactLine = highImpactChange ? "고영향 변경으로 분류되므로 build/test와 호출부 참조 근거를 모두 확보하세요." : "동일 도구 재시도 전에 읽기/참조/변경범위 근거를 먼저 확보하세요."; return "[System:NextToolPriority] 다음 반복에서는 아래 우선순위로 도구를 호출하세요.\n" + $"실패 도구: {failedToolName}\n" + $"실패 유형: {kindLabel}\n" + $"중점 파일: {fileHint}\n" + $"우선순위: {priority}\n" + $"작업유형: {taskPolicy.TaskType}\n" + highImpactLine; } private static string ResolveFailureKindPrioritySequence(string failedToolName, FailureRecoveryKind kind) { return kind switch { FailureRecoveryKind.Permission => "권한설정 확인 -> 대상 경로 재선정 -> file_read/grep으로 대체 검증 -> 승인 후 재실행", FailureRecoveryKind.Path => "folder_map/glob -> 절대경로 확인 -> file_read로 존재 검증 -> 원도구 재실행", FailureRecoveryKind.Command => "도구 스키마 재확인 -> 최소 파라미터 호출 -> 옵션 확장 -> 필요 시 대체 도구", FailureRecoveryKind.Dependency => "dev_env_detect/process 환경 확인 -> file_read/grep/git_tool 근거 확보 -> 가능한 범위 우회", FailureRecoveryKind.Timeout => "대상 범위 축소 -> 필터 적용 실행 -> 분할 실행 -> 최종 전체 실행", _ => failedToolName switch { "build_run" or "test_loop" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", "file_edit" or "file_write" => "file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop", "grep" or "glob" => "file_read -> folder_map -> grep/glob", _ => "file_read -> grep/glob -> git_tool(diff) -> targeted tool retry" } }; } private static string BuildFailureNextToolPriorityPrompt( string failedToolName, string? lastModifiedCodeFilePath, bool highImpactChange, string taskType) => BuildFailureNextToolPriorityPrompt( failedToolName, lastModifiedCodeFilePath, highImpactChange, TaskTypePolicy.FromTaskType(taskType), null); internal static bool TryParseApprovedPlanDecision( string? decision, out string planText, out List steps) { planText = ""; steps = new List(); if (string.IsNullOrWhiteSpace(decision)) return false; var normalized = decision.Trim(); if (!normalized.StartsWith(ApprovedPlanDecisionPrefix, StringComparison.Ordinal)) return false; var payload = normalized[ApprovedPlanDecisionPrefix.Length..].Trim(); if (string.IsNullOrWhiteSpace(payload)) return false; var extracted = TaskDecomposer.ExtractSteps(payload); if (extracted.Count == 0) return false; steps = extracted; planText = string.Join(Environment.NewLine, extracted.Select((step, idx) => $"{idx + 1}. {step}")); return true; } private static string TruncateOutput(string output, int maxLength) { if (output.Length <= maxLength) return output; return output[..maxLength] + "\n... (출력 잘림)"; } private void EmitPlanDecisionResultEvent(string? decision, List? steps = null) { if (decision == "취소") { EmitEvent(AgentEventType.Decision, "", "계획 반려 · 취소", steps: steps); return; } if (decision == null || decision == "승인") { EmitEvent(AgentEventType.Decision, "", "계획 승인", steps: steps); return; } if (TryParseApprovedPlanDecision(decision, out _, out var approvedSteps)) { EmitEvent(AgentEventType.Decision, "", $"계획 승인(편집 반영) · {approvedSteps.Count}단계", steps: approvedSteps); return; } if (decision == "수정 요청") { EmitEvent(AgentEventType.Decision, "", "계획 반려 · 수정 요청", steps: steps); return; } EmitEvent( AgentEventType.Decision, "", $"계획 반려 · {TruncateOutput(decision.Trim(), 120)}", steps: steps); } }