Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs
lacvet f53f35bbed
Some checks failed
Release Gate / gate (push) Has been cancelled
계획 모드 설정 제거와 플랜 승인 UX 정리
메인 설정과 AX Agent 내부 설정에서 계획 모드 UI를 숨기고 저장값을 항상 off로 고정했습니다.

AgentLoop 런타임도 계획 모드를 off로 고정해 코워크와 코드에서 자동 계획 승인 팝업이 반복 노출되지 않도록 정리했습니다.

PlanViewerWindow는 AX Agent 창 owner 리소스를 직접 받아 같은 테마 축을 따르도록 바꾸고 인라인 승인 버튼 중복 노출을 제거했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 오류 0
2026-04-05 14:12:22 +09:00

4833 lines
213 KiB
C#

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;
/// <summary>
/// 에이전트 루프 엔진: LLM과 대화하며 도구/스킬을 실행하는 반복 루프.
/// 계획 → 도구 실행 → 관찰 → 재평가 패턴을 구현합니다.
/// </summary>
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<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
public ObservableCollection<AgentEvent> Events { get; } = new();
/// <summary>현재 루프 실행 중 여부.</summary>
public bool IsRunning { get; private set; }
/// <summary>이벤트 발생 시 UI 스레드에서 호출할 디스패처.</summary>
public Action<Action>? Dispatcher { get; set; }
/// <summary>Ask 모드 권한 확인 콜백. (toolName, filePath) → bool</summary>
public Func<string, string, Task<bool>>? AskPermissionCallback { get; set; }
/// <summary>에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 응답.</summary>
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; set; }
/// <summary>현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용).</summary>
public string ActiveTab { get; set; } = "Chat";
/// <summary>현재 대화 ID (감사 로그 기록용).</summary>
private string _conversationId = "";
/// <summary>문서 생성 폴백 재시도 여부 (루프당 1회만).</summary>
private bool _docFallbackAttempted;
private string _currentRunId = "";
private bool _runPendingPostCompactionTurn;
private int _runPostCompactionTurnCounter;
private int _runPostCompactionSuppressedThinkingCount;
private string _runLastCompactionStageSummary = "";
private int _runLastCompactionSavedTokens;
private int _runPostCompactionToolResultCompactions;
/// <summary>일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.</summary>
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
/// <summary>현재 일시정지 상태 여부.</summary>
public bool IsPaused { get; private set; }
/// <summary>
/// 사용자 의사결정 콜백. 계획 제시 후 사용자 승인을 대기합니다.
/// (planSummary, options) → 선택된 옵션 텍스트. null이면 승인(계속 진행).
/// </summary>
public Func<string, List<string>, Task<string?>>? UserDecisionCallback { get; set; }
/// <summary>에이전트 이벤트 발생 시 호출되는 콜백 (UI 표시용).</summary>
public event Action<AgentEvent>? 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;
}
/// <summary>
/// 에이전트 루프를 일시정지합니다.
/// 다음 반복 시작 시점에서 대기 상태가 됩니다.
/// </summary>
public async Task PauseAsync()
{
if (IsPaused || !IsRunning) return;
// 세마포어를 획득하여 루프가 다음 반복에서 대기하게 함
await _pauseSemaphore.WaitAsync().ConfigureAwait(false);
IsPaused = true;
EmitEvent(AgentEventType.Paused, "", "에이전트가 일시정지되었습니다");
}
/// <summary>
/// 일시정지된 에이전트 루프를 재개합니다.
/// </summary>
public void Resume()
{
if (!IsPaused) return;
IsPaused = false;
try
{
_pauseSemaphore.Release();
}
catch (SemaphoreFullException)
{
// 이미 릴리즈된 상태 — 무시
}
EmitEvent(AgentEventType.Resumed, "", "에이전트가 재개되었습니다");
}
/// <summary>
/// 에이전트 루프를 실행합니다.
/// 사용자 메시지를 LLM에 전달하고, LLM이 도구를 호출하면 실행 후 결과를 다시 LLM에 피드백합니다.
/// LLM이 더 이상 도구를 호출하지 않으면 (텍스트만 반환) 루프를 종료합니다.
/// </summary>
/// <param name="messages">대화 메시지 목록 (시스템 프롬프트 포함)</param>
/// <param name="ct">취소 토큰</param>
/// <returns>최종 텍스트 응답</returns>
public async Task<string> RunAsync(List<ChatMessage> 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<string>();
// Task Decomposition: 계획 단계 추적
var planSteps = new List<string>();
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<string, int>(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 = ResolveEffectivePlanMode(llm.PlanMode, ActiveTab, taskPolicy.TaskType); // 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<string> { "승인", "수정 요청", "취소" });
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<string> { "승인", "수정 요청", "취소" });
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 compactionResult = await ContextCondenser.CondenseWithStatsAsync(
messages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
ct);
if (compactionResult.Changed)
{
MarkRunPostCompaction(compactionResult, runState);
var compactSummary = !string.IsNullOrWhiteSpace(compactionResult.StageSummary)
? compactionResult.StageSummary
: "기본";
EmitEvent(
AgentEventType.Thinking,
"",
$"컨텍스트 압축 완료 — {compactSummary} · {Services.TokenEstimator.Format(compactionResult.SavedTokens)} tokens 절감");
}
}
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<LlmService.ContentBlock> 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;
NotifyPostCompactionTurnIfNeeded(runState);
}
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<ChatMessage>
{
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<string>();
var toolCalls = new List<LlmService.ContentBlock>();
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<string> { "승인", "수정 요청", "취소" });
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<ChatMessage>
{
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<object>();
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,
BuildLoopToolResultMessage(effectiveCall, result, runState)));
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 = "";
_runPendingPostCompactionTurn = false;
_runPostCompactionTurnCounter = 0;
_runPostCompactionSuppressedThinkingCount = 0;
_runLastCompactionStageSummary = "";
_runLastCompactionSavedTokens = 0;
_runPostCompactionToolResultCompactions = 0;
// 일시정지 상태 리셋
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 compactNoiseSummary = _runPostCompactionSuppressedThinkingCount > 0
? $" | compact 로그 축약 {_runPostCompactionSuppressedThinkingCount}건"
: "";
var compactToolResultSummary = _runPostCompactionToolResultCompactions > 0
? $" | compact 결과 축약 {_runPostCompactionToolResultCompactions}건"
: "";
var topFailed = BuildTopFailureSummary(failedToolHistogram);
var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " +
$"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " +
$"소요 {durationSec:F1}초 | 재시도 품질 {retryQuality} (복구 {statsRecoveredAfterFailure}, 차단 {statsRepeatedFailureBlocks}){compactNoiseSummary}{compactToolResultSummary} | " +
$"실패 상위: {topFailed} | 사용 도구: {toolList}";
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
}
}
}
}
private SkillRuntimeOverrides? ResolveSkillRuntimeOverrides(List<ChatMessage> 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<string>(StringComparer.OrdinalIgnoreCase);
var hookNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var hookFilters = new List<HookFilterRule>();
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<IAgentTool> GetRuntimeActiveTools(
IEnumerable<string>? 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<string> MergeDisabledTools(IEnumerable<string>? disabledToolNames)
{
var disabled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (disabledToolNames != null)
{
foreach (var name in disabledToolNames)
{
if (!string.IsNullOrWhiteSpace(name))
disabled.Add(name);
}
}
if (!AgentTabSettingsResolver.IsCodeTab(ActiveTab))
return disabled;
var code = _settings.Settings.Llm.Code;
foreach (var toolName in AgentTabSettingsResolver.EnumerateCodeTabDisabledTools(code))
{
disabled.Add(toolName);
}
return disabled;
}
private static HashSet<string> ParseAllowedToolNames(string? raw)
{
var result = new HashSet<string>(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<string> ParseHookNames(string? raw)
{
var result = new HashSet<string>(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<HookFilterRule> ParseHookFilters(string? raw)
{
var result = new List<HookFilterRule>();
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<AgentHookEntry> GetRuntimeHooks(
IReadOnlyList<AgentHookEntry> 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<AgentHookEntry> GetRuntimeHooksForCall(
IReadOnlyList<AgentHookEntry> 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<string> AllowedToolNames,
IReadOnlySet<string> HookNames,
IReadOnlyList<HookFilterRule> HookFilters);
private sealed record HookFilterRule(
string HookName,
string Timing,
string ToolName,
bool IsExclude,
int Specificity,
int Order);
/// <summary>LLM 텍스트 응답을 HTML 보고서 파일로 자동 저장합니다.</summary>
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 = $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<title>{EscapeHtml(title)}</title>
<style>
{css}
.doc {{ max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }}
.doc h1 {{ font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }}
.doc h2 {{ font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }}
.doc h3 {{ font-size: 18px; margin-top: 24px; margin-bottom: 8px; }}
.doc .meta {{ color: #888; font-size: 13px; margin-bottom: 24px; }}
.doc p {{ line-height: 1.8; margin-bottom: 12px; }}
.doc ul, .doc ol {{ line-height: 1.8; margin-bottom: 16px; }}
.doc table {{ border-collapse: collapse; width: 100%; margin: 16px 0; }}
.doc th {{ background: var(--accent, #4B5EFC); color: #fff; padding: 10px 14px; text-align: left; }}
.doc td {{ padding: 8px 14px; border-bottom: 1px solid #e5e7eb; }}
.doc tr:nth-child(even) {{ background: #f8f9fa; }}
</style>
</head>
<body>
<div class=""doc"">
<h1>{EscapeHtml(title)}</h1>
<div class=""meta"">작성일: {DateTime.Now:yyyy-MM-dd} | AX Copilot 자동 생성</div>
{htmlBody}
</div>
</body>
</html>";
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;
}
}
/// <summary>LLM 텍스트(마크다운 형식)를 HTML로 변환합니다.</summary>
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($"</{listType}>"); inList = false; }
continue;
}
// 마크다운 제목
if (line.StartsWith("### "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line[2..])}</h2>");
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($"</{listType}>"); inList = false; }
sb.AppendLine($"<h2>{EscapeHtml(line)}</h2>");
}
else
{
if (!inList) { sb.AppendLine("<ol>"); inList = true; listType = "ol"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
}
continue;
}
// 불릿 리스트
if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* ") || line.TrimStart().StartsWith("• "))
{
var content = line.TrimStart()[2..].Trim();
if (!inList) { sb.AppendLine("<ul>"); inList = true; listType = "ul"; }
sb.AppendLine($"<li>{EscapeHtml(content)}</li>");
continue;
}
// 일반 텍스트
if (inList) { sb.AppendLine($"</{listType}>"); inList = false; }
sb.AppendLine($"<p>{EscapeHtml(line)}</p>");
}
if (inList) sb.AppendLine($"</{listType}>");
return sb.ToString();
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>사용자 요청이 문서/보고서 생성인지 판단합니다.</summary>
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));
}
/// <summary>문서 생성 도구인지 확인합니다 (Cowork 검증 대상).</summary>
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";
}
/// <summary>
/// 이 도구가 성공하면 작업이 완료된 것으로 간주해 루프를 즉시 종료합니다.
/// Ollama 등 멀티턴 tool_result 미지원 모델에서 불필요한 추가 LLM 호출과 "도구 호출 거부" 오류를 방지합니다.
/// document_assemble/html_create/docx_create 같은 최종 파일 생성 도구가 해당합니다.
/// </summary>
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";
}
/// <summary>코드 생성/수정 도구인지 확인합니다 (Code 검증 대상).</summary>
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";
}
internal static string ResolveEffectivePlanMode(string? configuredPlanMode, string? activeTab, string? taskType)
{
return "off";
}
private static void InjectTaskTypeGuidance(List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<string> SelectTopFailurePatterns(
IEnumerable<MemoryEntry> 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<string> SelectTopFailurePatterns(
IEnumerable<MemoryEntry> 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<string, string>(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<string, string>(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<ChatMessage> messages, AgentContext context, string userQuery, string taskType)
=> InjectRecentFailureGuidance(messages, context, userQuery, TaskTypePolicy.FromTaskType(taskType));
internal static string BuildFailurePatternGuidance(IReadOnlyCollection<string> 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<ParsedFailurePattern>()
.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<string> patterns, string taskType)
=> BuildFailurePatternGuidance(patterns, TaskTypePolicy.FromTaskType(taskType));
private static string BuildFailurePatternActionHints(
TaskTypePolicy taskPolicy,
IReadOnlyCollection<ParsedFailurePattern> 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",
};
}
/// <summary>
/// 문서 생성 도구 실행 후 검증 전용 LLM 호출을 삽입합니다.
/// LLM에게 생성된 파일을 읽고 품질을 평가하도록 강제합니다.
/// OpenHands 등 오픈소스에서는 이런 강제 검증이 없으며, AX Copilot 차별화 포인트입니다.
/// </summary>
/// <summary>읽기 전용 검증 도구 목록 (file_read만 허용)</summary>
private static readonly HashSet<string> 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<ChatMessage> messages, bool requireHighImpactEvidence = false)
{
var modificationTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "file_manage", "script_create"
};
var readTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"file_read"
};
var referenceTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"grep", "glob"
};
var delegatedEvidenceTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"wait_agents", "code_search", "lsp"
};
var diffTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"git_tool"
};
var executionTools = new HashSet<string>(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<ChatMessage> 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<ChatMessage> 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<ChatMessage> messages)
{
var modificationTools = new HashSet<string>(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<ChatMessage> messages)
{
var modificationTools = new HashSet<string>(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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<string> ExtractRecentFilePathHints(List<ChatMessage> messages, int maxHints = 8)
{
var hints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allowedExt = new HashSet<string>(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<string> 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<string> usedTools,
Dictionary<string, int> failedToolHistogram,
List<ChatMessage> 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<string, int> 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<string> 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<string>? policyAllowedToolNames,
IReadOnlyCollection<string> 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<string> 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<string> activeToolNames)
{
var preview = string.Join(", ", activeToolNames.Take(10));
return "⚠ 동일한 비허용 도구 호출이 반복되어 작업을 중단했습니다.\n" +
$"- 반복 횟수: {repeatedCount}\n" +
$"- 비허용 도구: {toolName}\n" +
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
"- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요.";
}
private static readonly Dictionary<string, string> 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<string> 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<char> 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<string> 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<string> 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<ChatMessage> 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<ChatMessage> { 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<string>();
var verifyToolCalls = new List<LlmService.ContentBlock>();
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<object>();
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<string, string>(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<HookExecutionResult> 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<ChatMessage> 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<ChatMessage>? 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<ToolResult?> EnforceToolPermissionAsync(
string toolName,
JsonElement input,
AgentContext context,
List<ChatMessage>? 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<string>? steps = null,
long elapsedMs = 0, int inputTokens = 0, int outputTokens = 0,
string? toolInput = null, int iteration = 0, bool? successOverride = null)
{
if (type == AgentEventType.Thinking && ShouldSuppressPostCompactionThinking(summary))
{
_runPostCompactionSuppressedThinkingCount++;
return;
}
// 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);
}
}
private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState)
{
runState.PendingPostCompactionTurn = true;
runState.PostCompactionTurnCounter = 0;
runState.LastCompactionStageSummary = result.StageSummary;
runState.LastCompactionSavedTokens = result.SavedTokens;
SyncRunPostCompactionState(runState);
}
private void NotifyPostCompactionTurnIfNeeded(RunState runState)
{
if (!runState.PendingPostCompactionTurn)
return;
runState.PendingPostCompactionTurn = false;
runState.PostCompactionTurnCounter++;
SyncRunPostCompactionState(runState);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "기본"
: runState.LastCompactionStageSummary;
var saved = runState.LastCompactionSavedTokens > 0
? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감"
: "";
EmitEvent(
AgentEventType.Thinking,
"",
$"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}");
}
private void SyncRunPostCompactionState(RunState runState)
{
_runPendingPostCompactionTurn = runState.PendingPostCompactionTurn;
_runPostCompactionTurnCounter = runState.PostCompactionTurnCounter;
_runLastCompactionStageSummary = runState.LastCompactionStageSummary ?? "";
_runLastCompactionSavedTokens = runState.LastCompactionSavedTokens;
_runPostCompactionToolResultCompactions = runState.PostCompactionToolResultCompactions;
}
private bool ShouldSuppressPostCompactionThinking(string summary)
{
if (string.IsNullOrWhiteSpace(summary))
return false;
var postCompactActive = _runPendingPostCompactionTurn || _runPostCompactionTurnCounter > 0;
if (!postCompactActive)
return false;
if (_runPostCompactionTurnCounter > 1)
return false;
return summary.StartsWith("LLM에 요청 중", StringComparison.Ordinal)
|| summary.StartsWith("사용자 프롬프트 제출", StringComparison.Ordinal)
|| summary.StartsWith("무료 티어 모드", StringComparison.Ordinal)
|| summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal);
}
private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState)
{
var output = result.Output ?? "";
if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output))
return TruncateOutput(output, 4000);
runState.PostCompactionToolResultCompactions++;
SyncRunPostCompactionState(runState);
var compacted = CompactToolResultForPostCompaction(call.ToolName, output);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "compact"
: runState.LastCompactionStageSummary;
return $"[POST_COMPACTION_TOOL_RESULT:{call.ToolName}] {stage}\n{compacted}";
}
private static bool ShouldCompactToolResultForPostCompactionTurn(RunState runState, string toolName, string output)
{
if (runState.PostCompactionTurnCounter != 1)
return false;
if (string.IsNullOrWhiteSpace(output))
return false;
if (output.Length < 900)
return false;
return toolName is "process" or "build_run" or "test_loop" or "snippet_runner" or "git_tool" or "http_tool"
|| output.Length > 1800;
}
private static string CompactToolResultForPostCompaction(string toolName, string output)
{
var normalized = output.Replace("\r\n", "\n");
var lines = normalized.Split('\n', StringSplitOptions.None)
.Select(line => line.TrimEnd())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (lines.Count == 0)
return TruncateOutput(output, 1200);
var headCount = toolName is "build_run" or "test_loop" or "process" ? 6 : 4;
var tailCount = toolName is "build_run" or "test_loop" or "process" ? 4 : 3;
var kept = new List<string>();
kept.AddRange(lines.Take(headCount));
if (lines.Count > headCount + tailCount)
kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]...");
if (lines.Count > headCount)
kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount)));
var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal));
return TruncateOutput(compacted, 1400);
}
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
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<string> steps)
{
planText = "";
steps = new List<string>();
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<string>? 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);
}
}