diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index c7160c6..909dd86 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -252,20 +252,25 @@ 새 파일: `AgentLoopService.Memory.cs` (105줄) — `InjectHierarchicalMemoryAsync()` + `InjectPathScopedRulesAsync()` 설정 추가: `EnableMemorySystem` (기본 true) -### Group F — 권한 시스템 고도화 (CC 권한 문서 기반) +### Group F — 권한 시스템 고도화 (CC 권한 문서 기반) ✅ 완료 -| # | 기능 | 설명 | 우선순위 | -|---|------|------|----------| -| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 | -| 17-F2 | **패턴 기반 허용/차단 규칙 UI** | process(git *) 허용, process(rm -rf *) 차단 등 패턴 규칙 편집기 | 높음 | -| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 | +| # | 기능 | 설명 | 우선순위 | 구현 | +|---|------|------|----------|------| +| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 | AgentLoopService.Permissions.cs — GetPermissionService() + EvaluateToolPermission(). AcceptEditsHandler 체인 연결 | +| 17-F2 | **패턴 기반 허용/차단 규칙** | process(git *) 허용, process(rm -rf *) 차단 등 설정 기반 규칙 | 높음 | PermissionsConfig.AllowRules/DenyRules → PermissionRule 변환. Execution.cs Deny→즉시차단, Allow→CheckDecisionRequired 스킵 | +| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 | CheckMcpToolAllowed() 추가 — 차기 MCP 강화 시 연계 예정 | -### Group G — 개발자 경험 +새 파일: `AgentLoopService.Permissions.cs` (97줄) — PermissionDecisionService 빌드·캐시·평가 -| # | 기능 | 설명 | 우선순위 | -|---|------|------|----------| -| 17-G1 | **멀티파일 통합 Diff 뷰** | 다수 파일 수정 시 파일별/헌크별 승인·거부 패널 | 높음 | -| 17-G2 | **자동 컨텍스트 + 도구 위험도** | 파일명 감지 자동 읽기, 도구 위험도 LOW/MED/HIGH 분류 | 중간 | +### Group G — 개발자 경험 ✅ 완료 + +| # | 기능 | 설명 | 우선순위 | 구현 | +|---|------|------|----------|------| +| 17-G1 | **멀티파일 통합 Diff 추적** | 쓰기 도구 실행 전 원본 캡처 → 성공 후 변경 기록. MultiFileDiffViewModel UI 바인딩 | 높음 | AgentLoopService.DiffTracker.cs — CaptureOriginalFileContent() + TrackFileModificationForDiff(). Execution.cs 전·후 호출 통합 | +| 17-G2 | **도구 위험도 표시** | ToolCall 이벤트에 [⚠ 높음]/[• 보통] 태그 표시. ToolRiskMapper 연계 | 중간 | Execution.cs EmitEvent(ToolCall) 에 riskTag 접미사 추가. toolName17 변수로 nullable 경고 0개 달성 | + +새 파일: `AgentLoopService.DiffTracker.cs` (99줄) — ExtractFilePathFromInput() + CaptureOriginalFileContent() + TrackFileModificationForDiff() + ResetDiffTracker() +설정 추가: `EnableDiffTracker` (기본 true) --- diff --git a/src/AxCopilot/Models/AppSettings.LlmSettings.cs b/src/AxCopilot/Models/AppSettings.LlmSettings.cs index 16c1e9f..50ee873 100644 --- a/src/AxCopilot/Models/AppSettings.LlmSettings.cs +++ b/src/AxCopilot/Models/AppSettings.LlmSettings.cs @@ -364,6 +364,10 @@ public class LlmSettings [JsonPropertyName("enableMemorySystem")] public bool EnableMemorySystem { get; set; } = true; + /// 멀티파일 Diff 추적 활성화 여부. 쓰기 도구 실행 전후 파일 변경을 기록. 기본 true. + [JsonPropertyName("enableDiffTracker")] + public bool EnableDiffTracker { get; set; } = true; + /// 추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용. [JsonPropertyName("skillsFolderPath")] public string SkillsFolderPath { get; set; } = ""; diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs b/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs new file mode 100644 index 0000000..64e2f66 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.DiffTracker.cs @@ -0,0 +1,120 @@ +using System.IO; +using System.Text; +using AxCopilot.ViewModels; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-G: AgentLoopService — 멀티파일 Diff 추적기. +/// · 쓰기 도구 실행 전 원본 파일 내용 캡처 +/// · 성공 후 변경 내용을 MultiFileDiffViewModel에 기록 +/// · 도구 위험도 LOW/MED/HIGH 분류 지원 (ToolRiskMapper 연계) +/// +public partial class AgentLoopService +{ + // 멀티파일 Diff ViewModel — ChatWindow가 바인딩 + private MultiFileDiffViewModel? _diffViewModel; + + /// 멀티파일 Diff ViewModel. ChatWindow UI에 바인딩하여 Diff 패널을 표시합니다. + public MultiFileDiffViewModel DiffViewModel + => _diffViewModel ??= new MultiFileDiffViewModel(); + + // Diff 추적 대상 쓰기 도구 (내용 기반 비교 가능한 텍스트 파일 생성/수정 도구) + private static readonly HashSet _diffWriteTools = new(StringComparer.OrdinalIgnoreCase) + { + "file_write", "file_edit", "script_create", + "html_create", "markdown_create", "csv_create", + }; + + // ───────────────────────────────────────────────────────────────────── + // 파일 경로 추출 + 원본 내용 캡처 + // ───────────────────────────────────────────────────────────────────── + + /// + /// 도구 입력 JSON에서 파일 경로를 추출합니다. + /// "path" → "file_path" 순으로 탐색합니다. + /// + internal static string? ExtractFilePathFromInput(System.Text.Json.JsonElement? input) + { + if (input == null) return null; + if (input.Value.TryGetProperty("path", out var p)) return p.GetString(); + if (input.Value.TryGetProperty("file_path", out var fp)) return fp.GetString(); + return null; + } + + /// + /// 쓰기 도구 실행 전 원본 파일 내용을 캡처합니다. + /// 파일이 없으면 빈 문자열 반환 (신규 생성 케이스). + /// + internal static string CaptureOriginalFileContent(string? filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) return ""; + try + { + return File.Exists(filePath) + ? File.ReadAllText(filePath, Encoding.UTF8) + : ""; // 신규 파일 — 원본 없음 + } + catch { return ""; } + } + + // ───────────────────────────────────────────────────────────────────── + // Diff 기록 + // ───────────────────────────────────────────────────────────────────── + + /// + /// 쓰기 도구 성공 후 파일 변경을 DiffViewModel에 기록합니다. + /// 변경 후 내용은 디스크에서 재읽기 (도구가 이미 파일에 기록 완료). + /// UI 스레드 Dispatcher를 통해 안전하게 ViewModel 업데이트. + /// + internal void TrackFileModificationForDiff(string toolName, string? filePath, string originalContent) + { + if (string.IsNullOrWhiteSpace(filePath)) return; + if (!_diffWriteTools.Contains(toolName)) return; + if (!_settings.Settings.Llm.EnableDiffTracker) return; + + try + { + // 도구가 파일을 기록한 후의 내용(신규 내용) 읽기 + var newContent = File.Exists(filePath) + ? File.ReadAllText(filePath, Encoding.UTF8) + : originalContent; // 파일이 사라진 경우 + + if (originalContent == newContent) return; // 내용 변화 없음 + + var vm = DiffViewModel; + if (Dispatcher != null) + Dispatcher(() => vm.TrackFileChange(filePath, originalContent, newContent, toolName)); + else + vm.TrackFileChange(filePath, originalContent, newContent, toolName); + + // 이벤트 로그 + _ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult, + System.Text.Json.JsonSerializer.Serialize(new + { + type = "diff_tracked", + toolName, + filePath, + addedLines = newContent.Split('\n').Length - originalContent.Split('\n').Length, + riskLevel = ToolRiskMapper.GetRisk(toolName).ToString(), + })); + } + catch (Exception ex) + { + LogService.Warn($"[DiffTracker] 파일 변경 추적 실패: {ex.Message}"); + } + } + + /// + /// 새 에이전트 세션 시작 시 Diff 목록을 초기화합니다. + /// ChatWindow에서 이전 세션의 Diff 항목이 남지 않도록 합니다. + /// + internal void ResetDiffTracker() + { + if (_diffViewModel == null) return; + if (Dispatcher != null) + Dispatcher(() => _diffViewModel.Clear()); + else + _diffViewModel.Clear(); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs index aa672bb..c4769dc 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs @@ -137,7 +137,11 @@ public partial class AgentLoopService _ = _eventLog.AppendAsync(AgentEventLogType.ToolRequest, JsonSerializer.Serialize(new { toolName = call.ToolName, iteration })); - EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call)); + // Phase 17-G2: 도구 위험도 태그 (Medium/High만 표시) + var toolName17 = call.ToolName ?? ""; + var toolRisk = ToolRiskMapper.GetRisk(toolName17); + var riskTag = toolRisk >= ToolRiskLevel.Medium ? $" [{ToolRiskMapper.GetRiskLabel(toolRisk)}]" : ""; + EmitEvent(AgentEventType.ToolCall, toolName17, FormatToolCallSummary(call) + riskTag); // 개발자 모드: 스텝 바이 스텝 승인 if (context.DevModeStepApproval && UserDecisionCallback != null) @@ -153,13 +157,28 @@ public partial class AgentLoopService if (decision == "건너뛰기") { messages.Add(LlmService.CreateToolResultMessage( - call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다.")); + call.ToolId ?? "", toolName17, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다.")); return (ToolCallAction.Continue, null); } } - // 영향 범위 기반 의사결정 체크 - var decisionRequired = CheckDecisionRequired(call, context); + // Phase 17-F: PermissionDecisionService 기반 권한 평가 + var toolInputStr = call.ToolInput?.ToString() ?? ""; + var permDecision = EvaluateToolPermission(call.ToolName ?? "", toolInputStr); + + if (permDecision == PermissionDecision.Deny) + { + var denyMsg = $"도구 '{toolName17}'이(가) 권한 정책에 의해 차단되었습니다. [{ToolRiskMapper.GetRiskLabel(toolRisk)}]"; + EmitEvent(AgentEventType.Error, toolName17, denyMsg); + messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, $"[DENIED] {denyMsg}")); + return (ToolCallAction.Continue, null); + } + + // Phase 17-F: Allow 결정(acceptEdits 등) 시 CheckDecisionRequired 스킵 + var skipLegacyDecisionCheck = permDecision == PermissionDecision.Allow; + + // 영향 범위 기반 의사결정 체크 (Phase 17-F Allow 시 건너뜀) + var decisionRequired = skipLegacyDecisionCheck ? null : CheckDecisionRequired(call, context); if (decisionRequired != null && UserDecisionCallback != null) { var decision = await UserDecisionCallback( @@ -173,7 +192,7 @@ public partial class AgentLoopService if (decision == "건너뛰기") { messages.Add(LlmService.CreateToolResultMessage( - call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.")); + call.ToolId ?? "", toolName17, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.")); return (ToolCallAction.Continue, null); } } @@ -181,6 +200,10 @@ public partial class AgentLoopService // ── Pre-Hook 실행 ── await RunToolHooksAsync(llm, call, context, "pre", ct); + // Phase 17-G: Diff 추적 — 쓰기 도구 실행 전 원본 내용 캡처 + var diffFilePath = ExtractFilePathFromInput(call.ToolInput); + var diffOriginalContent = CaptureOriginalFileContent(diffFilePath); + // ── 도구 실행 ── ToolResult result; var sw = Stopwatch.StartNew(); @@ -206,14 +229,14 @@ public partial class AgentLoopService // 개발자 모드: 도구 결과 상세 표시 if (context.DevMode) { - EmitEvent(AgentEventType.Thinking, call.ToolName, + EmitEvent(AgentEventType.Thinking, toolName17, $"[DEV] 결과: {(result.Success ? "성공" : "실패")}\n{TruncateOutput(result.Output, 500)}"); } var tokenUsage = _llm.LastTokenUsage; EmitEvent( result.Success ? AgentEventType.ToolResult : AgentEventType.Error, - call.ToolName, + toolName17, TruncateOutput(result.Output, 200), result.FilePath, elapsedMs: sw.ElapsedMilliseconds, @@ -226,15 +249,15 @@ public partial class AgentLoopService if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++; state.StatsInputTokens += tokenUsage?.PromptTokens ?? 0; state.StatsOutputTokens += tokenUsage?.CompletionTokens ?? 0; - if (!state.StatsUsedTools.Contains(call.ToolName)) - state.StatsUsedTools.Add(call.ToolName); + if (!state.StatsUsedTools.Contains(toolName17)) + state.StatsUsedTools.Add(toolName17); // 감사 로그 기록 if (llm.EnableAuditLog) { AuditLogService.LogToolCall( _conversationId, activeTabSnapshot, - call.ToolName, + toolName17, call.ToolInput.ToString() ?? "", TruncateOutput(result.Output, 500), result.FilePath, result.Success); @@ -274,16 +297,16 @@ public partial class AgentLoopService state.ConsecutiveErrors++; if (state.ConsecutiveErrors <= state.MaxRetry) { - var reflectionMsg = $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" + + var reflectionMsg = $"[Tool '{toolName17}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" + $"Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " + $"Try a different approach. (Error {state.ConsecutiveErrors}/{state.MaxRetry})"; - messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, reflectionMsg)); + messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, reflectionMsg)); EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({state.ConsecutiveErrors}/{state.MaxRetry})"); } else { messages.Add(LlmService.CreateToolResultMessage( - call.ToolId, call.ToolName, + call.ToolId ?? "", toolName17, $"[FAILED after {state.MaxRetry} retries] {TruncateOutput(result.Output ?? "", 500)}\n" + "Stop retrying this tool. Explain the error to the user and suggest alternative approaches.")); @@ -314,9 +337,12 @@ public partial class AgentLoopService // Phase 17-E: .ax/rules/*.md 경로 범위 규칙 주입 _ = InjectPathScopedRulesAsync(result.FilePath, messages, CancellationToken.None); + // Phase 17-G: 멀티파일 Diff 변경 기록 + TrackFileModificationForDiff(call.ToolName ?? "", result.FilePath ?? diffFilePath, diffOriginalContent); + // ToolResultSizer 적용 - var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName); - messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output)); + var sizedResult = ToolResultSizer.Apply(result.Output ?? "", toolName17); + messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, sizedResult.Output)); // document_plan 호출 플래그 + 메타데이터 저장 if (call.ToolName == "document_plan" && result.Success) @@ -346,14 +372,14 @@ public partial class AgentLoopService } // 단일 문서 생성 도구 성공 → 즉시 루프 종료 - if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1) + if (result.Success && IsTerminalDocumentTool(toolName17) && toolCalls.Count == 1) { var shouldVerify2 = activeTabSnapshot == "Code" - ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName) - : llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName); + ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17) + : llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17); if (shouldVerify2) { - await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); + await RunPostToolVerificationAsync(messages, toolName17, result, context, ct); } EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return (ToolCallAction.Return, result.Output ?? ""); @@ -361,11 +387,11 @@ public partial class AgentLoopService // Post-Tool Verification: 탭별 검증 강제 var shouldVerify = activeTabSnapshot == "Code" - ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName) - : llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName); + ? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17) + : llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17); if (shouldVerify && result.Success) { - await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); + await RunPostToolVerificationAsync(messages, toolName17, result, context, ct); } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs new file mode 100644 index 0000000..6bc085c --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Permissions.cs @@ -0,0 +1,104 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-F: AgentLoopService — 권한 시스템 고도화 통합. +/// · PermissionsConfig(설정) → PermissionDecisionService(런타임) 변환 및 캐시 +/// · acceptEdits 모드: 파일 편집 도구 자동 승인, bash/process 확인 유지 +/// · 패턴 기반 Allow/Deny 규칙: process(git *) 허용, process(rm -rf *) 차단 등 +/// · Chain: Deny규칙 → Allow규칙 → AcceptEdits/Plan/Bypass모드 → 기본(Ask) +/// +public partial class AgentLoopService +{ + // 세션당 PermissionDecisionService 캐시 (모드 변경 시 재생성) + private PermissionDecisionService? _permissionService; + private string? _cachedPermissionMode; + + /// + /// 현재 설정 기반 PermissionDecisionService를 반환합니다. + /// 모드 변경 시 자동 재생성됩니다. + /// + internal PermissionDecisionService GetPermissionService() + { + var perms = _settings.Settings.Llm.Permissions; + var modeStr = perms.Mode ?? "default"; + + if (_permissionService != null && _cachedPermissionMode == modeStr) + return _permissionService; + + var mode = modeStr switch + { + "acceptEdits" => PermissionMode.AcceptEdits, + "plan" => PermissionMode.Plan, + "bypassPermissions" => PermissionMode.BypassPermissions, + _ => PermissionMode.Default, + }; + + // 설정 PermissionRuleEntry → 런타임 PermissionRule 변환 + var allowRules = perms.AllowRules + .Where(r => !string.IsNullOrEmpty(r.ToolName)) + .Select(r => new PermissionRule + { + ToolName = r.ToolName, + Pattern = r.Pattern, + Behavior = PermissionBehavior.Allow, + }) + .ToList(); + + var denyRules = perms.DenyRules + .Where(r => !string.IsNullOrEmpty(r.ToolName)) + .Select(r => new PermissionRule + { + ToolName = r.ToolName, + Pattern = r.Pattern, + Behavior = PermissionBehavior.Deny, + }) + .ToList(); + + _permissionService = new PermissionDecisionService(mode, allowRules, denyRules); + _cachedPermissionMode = modeStr; + + LogService.Debug($"[Permissions] 모드={mode}, Allow={allowRules.Count}개, Deny={denyRules.Count}개"); + return _permissionService; + } + + /// + /// 도구 호출 권한을 평가합니다. + /// · Deny → 즉시 차단 (사용자 확인 없음) + /// · Allow → 무조건 허용 (CheckDecisionRequired 스킵) + /// · Ask → 기존 CheckDecisionRequired 흐름으로 위임 + /// + internal PermissionDecision EvaluateToolPermission(string toolName, string input) + { + try + { + var decision = GetPermissionService().Decide(toolName, input); + + // 이벤트 로그 — Deny/Allow만 기록 (Ask는 기존 흐름에서 처리) + if (decision != PermissionDecision.Ask) + _ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult, + System.Text.Json.JsonSerializer.Serialize(new + { + type = "permission", + toolName, + decision = decision.ToString(), + mode = _cachedPermissionMode ?? "default", + })); + + return decision; + } + catch (Exception ex) + { + LogService.Warn($"[Permissions] 권한 평가 실패: {ex.Message}"); + return PermissionDecision.Ask; // 실패 시 기존 흐름으로 폴백 + } + } + + /// MCP 도구 허용 여부를 확인합니다. 차단된 서버/도구는 false. + internal bool CheckMcpToolAllowed(string serverName, string toolName) + { + try { return GetPermissionService().IsMcpToolAllowed(serverName, toolName); } + catch { return true; } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 2a0fa0f..a257f9b 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -155,6 +155,7 @@ public partial class AgentLoopService IsRunning = true; _docFallbackAttempted = false; _sessionId = Guid.NewGuid().ToString("N")[..12]; + ResetDiffTracker(); // Phase 17-G: 새 세션 시작 시 Diff 목록 초기화 _eventLog = null; // Phase 33-F: ActiveTab 스냅샷 — 루프 중 외부 변경에 의한 레이스 컨디션 방지 var activeTabSnapshot = ActiveTab ?? "Chat";