- /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 추가 진행 이력 기록
4700 lines
207 KiB
C#
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("&", "&").Replace("<", "<").Replace(">", ">");
|
|
|
|
/// <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);
|
|
}
|
|
|
|
}
|