Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs
lacvet 52e9e34ade AX Agent MCP 명령 확장 및 에이전트 복구/슬래시 UX 보강
- /mcp 하위 명령 확장: add/remove/reset 지원, 도움말/상태 문구 동기화

- add 파서 추가: stdio(command+args), sse(url) 형식 검증 및 중복 서버명 방지

- remove all/단건 및 reset(세션 MCP 오버라이드 초기화) 실행 경로 구현

- Agentic loop 복구 프롬프트 강화: 미등록/비허용 도구 상황에서 tool_search 우선 가이드 적용

- 반복 실패 중단 응답에 재시도 루트 명시로 루프 복구 가능성 개선

- 슬래시 팝업 힌트 밀도 개선: agentUiExpressionLevel(rich/balanced/simple) 연동

- 테스트 보강: ChatWindowSlashPolicyTests(/mcp add/remove/reset, add 파서, 토크나이저), AgentLoopCodeQualityTests(tool_search 복구 가이드)

- 문서 반영: docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md에 2026-04-04 추가 진행 이력 기록
2026-04-04 01:20:34 +09:00

4700 lines
207 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 = "";
/// <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 = llm.PlanMode ?? "off"; // off | always | auto
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides);
var runtimeOverrideApplied = false;
string? lastUserPromptHookFingerprint = null;
var enforceForkExecution =
runtimeOverrides?.RequireForkExecution == true &&
llm.EnableForkSkillDelegationEnforcement;
var forkEnforcementAttempts = 0;
var forkDelegationObserved = false;
if (runtimeOverrides != null)
{
_llm.PushInferenceOverride(
runtimeOverrides.Service,
runtimeOverrides.Model,
runtimeOverrides.Temperature,
runtimeOverrides.ReasoningEffort);
runtimeOverrideApplied = true;
EmitEvent(
AgentEventType.Thinking,
"",
$"[SkillRuntime] model={runtimeOverrides.Model ?? "()"} service={runtimeOverrides.Service ?? "()"} effort={runtimeOverrides.ReasoningEffort ?? "()"}" +
(runtimeOverrides.AllowedToolNames.Count > 0
? $" allowed_tools={runtimeOverrides.AllowedToolNames.Count}개"
: "") +
((runtimeOverrides.HookNames.Count > 0 || runtimeOverrides.HookFilters.Count > 0)
? $" hooks={runtimeHooks.Count}개"
: ""));
}
async Task RunRuntimeHooksAsync(
string hookToolName,
string timing,
string? inputJson = null,
string? output = null,
bool success = true)
{
if (!llm.EnableToolHooks || runtimeHooks.Count == 0)
return;
try
{
var selected = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, hookToolName, timing);
var results = await AgentHookRunner.RunAsync(
selected,
hookToolName,
timing,
toolInput: inputJson,
toolOutput: output,
success: success,
workFolder: context.WorkFolder,
timeoutMs: llm.ToolHookTimeoutMs,
ct: ct);
foreach (var pr in results)
{
EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success);
ApplyHookAdditionalContext(messages, pr);
ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate);
}
}
catch
{
// lifecycle hook failure is non-blocking
}
}
try
{
EmitEvent(AgentEventType.SessionStart, "", "에이전트 세션 시작");
await RunRuntimeHooksAsync(
"__session_start__",
"pre",
JsonSerializer.Serialize(new
{
runId = _currentRunId,
tab = ActiveTab,
workFolder = context.WorkFolder
}));
// ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ──
if (planMode == "always")
{
iteration++;
EmitEvent(AgentEventType.Thinking, "", "실행 계획 생성 중...");
// 계획 생성 전용 시스템 지시를 임시 추가
var planInstruction = new ChatMessage
{
Role = "user",
Content = "[System] 도구를 호출하지 마세요. 먼저 실행 계획을 번호 매긴 단계로 작성하세요. " +
"각 단계에 사용할 도구와 대상을 구체적으로 명시하세요. " +
"계획만 제시하고 실행은 하지 마세요."
};
messages.Add(planInstruction);
// 도구 없이 텍스트만 요청
string planText;
try
{
planText = await _llm.SendAsync(messages, ct);
}
catch (Exception ex)
{
EmitEvent(AgentEventType.Error, "", $"LLM 오류: {ex.Message}");
return $"⚠ LLM 오류: {ex.Message}";
}
// 계획 지시 메시지 제거 (실제 실행 시 혼란 방지)
messages.Remove(planInstruction);
// 계획 추출
planSteps = TaskDecomposer.ExtractSteps(planText);
planExtracted = true;
if (planSteps.Count > 0)
{
EmitEvent(AgentEventType.Planning, "", $"작업 계획: {planSteps.Count}단계",
steps: planSteps);
// 사용자 승인 대기
if (UserDecisionCallback != null)
{
EmitEvent(
AgentEventType.Decision,
"",
$"계획 확인 대기 · {planSteps.Count}단계",
steps: planSteps);
var decision = await UserDecisionCallback(
planText,
new List<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 condensed = await ContextCondenser.CondenseIfNeededAsync(
messages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
ct);
if (condensed)
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다");
}
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
// 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
if (llm.FreeTierMode && iteration > 1)
{
var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : 4;
EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중...");
await Task.Delay(delaySec * 1000, ct);
}
var latestUserPrompt = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content;
if (!string.IsNullOrWhiteSpace(latestUserPrompt))
{
var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}";
if (!string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal))
{
lastUserPromptHookFingerprint = fingerprint;
EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출");
await RunRuntimeHooksAsync(
"__user_prompt_submit__",
"pre",
JsonSerializer.Serialize(new
{
prompt = TruncateOutput(latestUserPrompt, 4000),
runId = _currentRunId,
tab = ActiveTab
}));
}
}
// LLM에 도구 정의와 함께 요청
List<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;
}
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, TruncateOutput(result.Output, 4000)));
if (TryHandleReadOnlyStagnationTransition(
consecutiveReadOnlySuccessTools,
messages,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy))
{
consecutiveReadOnlySuccessTools = 0;
continue;
}
if (TryHandleNoProgressExecutionTransition(
consecutiveNonMutatingSuccessTools,
messages,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy,
documentPlanPath,
runState))
{
consecutiveNonMutatingSuccessTools = 0;
continue;
}
if (ShouldAbortNoProgressExecution(
consecutiveNonMutatingSuccessTools,
runState.NoProgressRecoveryRetry))
{
EmitEvent(AgentEventType.Error, "", "비진행 상태가 장시간 지속되어 작업을 중단합니다.");
return BuildNoProgressAbortResponse(
taskPolicy,
consecutiveNonMutatingSuccessTools,
runState.NoProgressRecoveryRetry,
lastArtifactFilePath,
statsUsedTools);
}
ApplyDocumentPlanSuccessTransitions(
effectiveCall,
result,
messages,
ref documentPlanCalled,
ref documentPlanPath,
ref documentPlanTitle,
ref documentPlanScaffold);
var (terminalCompleted, consumedExtraIteration) = await TryHandleTerminalDocumentCompletionTransitionAsync(
effectiveCall,
result,
toolCalls,
messages,
llm,
context,
ct);
if (terminalCompleted)
{
if (consumedExtraIteration)
iteration++;
return result.Output;
}
var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync(
effectiveCall,
result,
messages,
llm,
context,
ct);
if (consumedVerificationIteration)
iteration++; // 검증 LLM 호출도 반복 횟수에 포함
ApplyCodeQualityFollowUpTransition(
effectiveCall,
result,
messages,
taskPolicy,
ref requireHighImpactCodeVerification,
ref lastModifiedCodeFilePath);
}
}
}
if (iteration >= maxIterations)
{
EmitEvent(AgentEventType.Error, "", $"최대 반복 횟수 도달 ({maxIterations}회)");
return BuildIterationLimitFallbackResponse(
maxIterations,
taskPolicy,
totalToolCalls,
statsSuccessCount,
statsFailCount,
lastArtifactFilePath,
statsUsedTools,
failedToolHistogram,
messages);
}
if (ct.IsCancellationRequested)
{
EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다");
await RunRuntimeHooksAsync(
"__stop_requested__",
"post",
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
"cancelled",
success: false);
}
return "(취소됨)";
}
finally
{
if (runtimeOverrideApplied)
_llm.PopInferenceOverride();
IsRunning = false;
_currentRunId = "";
// 일시정지 상태 리셋
if (IsPaused)
{
IsPaused = false;
try { _pauseSemaphore.Release(); }
catch (SemaphoreFullException) { }
}
// 통계 기록 (도구 호출이 1회 이상인 세션만)
if (totalToolCalls > 0)
{
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
{
Timestamp = statsStart,
Tab = ActiveTab ?? "",
TaskType = taskPolicy.TaskType,
Model = _settings.Settings.Llm.Model ?? "",
ToolCalls = totalToolCalls,
SuccessCount = statsSuccessCount,
FailCount = statsFailCount,
InputTokens = statsInputTokens,
OutputTokens = statsOutputTokens,
DurationMs = durationMs,
RepeatedFailureBlockedCount = statsRepeatedFailureBlocks,
RecoveredAfterFailureCount = statsRecoveredAfterFailure,
UsedTools = statsUsedTools,
});
// 전체 호출·토큰 합계 표시 (개발자 모드 설정)
if (llm.ShowTotalCallStats)
{
var totalTokens = statsInputTokens + statsOutputTokens;
var durationSec = durationMs / 1000.0;
var toolList = string.Join(", ", statsUsedTools);
var retryTotal = statsRepeatedFailureBlocks + statsRecoveredAfterFailure;
var retryQuality = retryTotal > 0
? $"{(statsRecoveredAfterFailure * 100.0 / retryTotal):F0}%"
: "100%";
var topFailed = BuildTopFailureSummary(failedToolHistogram);
var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " +
$"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " +
$"소요 {durationSec:F1}초 | 재시도 품질 {retryQuality} (복구 {statsRecoveredAfterFailure}, 차단 {statsRepeatedFailureBlocks}) | " +
$"실패 상위: {topFailed} | 사용 도구: {toolList}";
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
}
}
}
}
private SkillRuntimeOverrides? ResolveSkillRuntimeOverrides(List<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 (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return disabled;
var code = _settings.Settings.Llm.Code;
if (!code.EnablePlanModeTools)
{
disabled.Add("enter_plan_mode");
disabled.Add("exit_plan_mode");
}
if (!code.EnableWorktreeTools)
{
disabled.Add("enter_worktree");
disabled.Add("exit_worktree");
}
if (!code.EnableTeamTools)
{
disabled.Add("team_create");
disabled.Add("team_delete");
}
if (!code.EnableCronTools)
{
disabled.Add("cron_create");
disabled.Add("cron_delete");
disabled.Add("cron_list");
}
return disabled;
}
private static HashSet<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";
}
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)
{
// 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);
}
}
/// <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);
}
}