From 0ceca202e9d223e379fcaff315a88e82722268f8 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 9 Apr 2026 00:57:38 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20loop=20=EC=A0=95=EC=B1=85=EC=9D=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20transcript=20?= =?UTF-8?q?=EA=B0=80=EC=83=81=ED=99=94=C2=B7=EC=84=B1=EB=8A=A5=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20=EA=B0=95=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentLoop 검증/문서/compact 정책 메서드를 partial 파일로 분리해 loop 본문의 책임을 줄임 - transcript 렌더에 cache pruning과 deferred scrolling을 적용해 긴 세션의 UI 부담을 낮춤 - AgentPerformanceLogService를 추가해 transcript 렌더와 agent loop 실행 요약을 perf 로그로 남김 - README와 DEVELOPMENT 문서에 2026-04-09 10:08 (KST) 기준 구조 개선 및 계측 이력을 반영함 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 검증 결과 경고 0개 오류 0개 --- README.md | 5 + docs/DEVELOPMENT.md | 22 + .../Agent/AgentLoopCompactionPolicy.cs | 116 ++++++ .../Services/Agent/AgentLoopService.cs | 137 ++----- .../Agent/AgentLoopTransitions.Documents.cs | 156 +++++++ .../Agent/AgentLoopTransitions.Execution.cs | 382 ------------------ .../AgentLoopTransitions.Verification.cs | 233 +++++++++++ .../Services/AgentPerformanceLogService.cs | 72 ++++ .../Views/ChatWindow.TranscriptRendering.cs | 25 ++ .../ChatWindow.TranscriptVirtualization.cs | 25 ++ src/AxCopilot/Views/ChatWindow.xaml | 3 + 11 files changed, 682 insertions(+), 494 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs create mode 100644 src/AxCopilot/Services/AgentPerformanceLogService.cs create mode 100644 src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs diff --git a/README.md b/README.md index 597ef09..e8c407e 100644 --- a/README.md +++ b/README.md @@ -1531,3 +1531,8 @@ MIT License - transcript 렌더 구조를 planning/execution 단계로 한 번 더 쪼갰습니다. [ChatWindow.TranscriptRenderPlanner.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRenderPlanner.cs) 에서 visible window 계산, render key 집계, 전체/증분 렌더 계획 생성을 맡기고, [ChatWindow.TranscriptRenderExecution.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRenderExecution.cs) 에서 host 적용과 viewport 보존을 맡기도록 정리했습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)의 `RenderMessages()`는 이제 `데이터 수집 -> render plan 생성 -> 증분/전체 적용`만 오케스트레이션하는 얇은 진입점이 됐습니다. - `claw-code`의 `Messages.tsx`와 `VirtualMessageList.tsx`처럼 transcript planning과 실제 host 조작을 분리하는 방향에 더 가까워졌고, 이후 실제 가상화 윈도우 정책을 다듬을 때 변경 범위를 더 안전하게 제한할 수 있게 됐습니다. +- 업데이트: 2026-04-09 10:08 (KST) + - 구조 개선과 실검증을 함께 하기 위해 loop 정책과 transcript 호스트에 성능 계측을 추가했습니다. [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)가 `%APPDATA%\\AxCopilot\\perf`에 transcript 렌더 시간과 agent loop 실행 요약을 JSON 로그로 남깁니다. + - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. + - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. + - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 070ab41..403eb22 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5536,3 +5536,25 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 구조 효과 - transcript 렌더의 변경 포인트가 `계획 생성`과 `실행 적용`으로 명확히 나뉘었다. - 향후 실제 가상화 강화, render batching, incremental diff 정교화 작업을 더 안전하게 진행할 수 있다. + +## 2026-04-09 10:08 (KST) + +- [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs) + - `%APPDATA%\AxCopilot\perf\performance-YYYY-MM-DD.json`에 transcript 렌더 및 agent loop 성능 로그를 남기는 전용 서비스를 추가했다. + - 구조 개선 후 실제 Cowork/Code 세션에서 렌더 시간과 루프 duration을 수치로 확인할 수 있게 해, 향후 병목 판단을 코드 추정이 아니라 로그 기반으로 할 수 있게 했다. +- [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs) + - transcript에 보이지 않는 오래된 버블 캐시를 pruning하는 정책을 추가했다. + - `_elementCache`가 실행 중 계속 커지는 대신 최근 visible/rendered key 위주로 유지되도록 바꿔, 긴 세션에서 메모리와 visual 재사용 비용을 더 안정적으로 관리하게 했다. +- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs) + - `RenderMessages()`에 `Stopwatch` 기반 계측을 추가하고, 렌더 시간이 24ms 이상이거나 스트리밍 중이면 transcript 성능 로그를 남기도록 했다. + - 로그 detail에는 `visibleMessages`, `visibleEvents`, `renderedItems`, `hiddenCount`, `lightweight` 여부가 포함돼 transcript 버벅임의 실제 상황을 재현하기 쉬워졌다. +- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) + - transcript `ListBox`에 `ScrollViewer.IsDeferredScrollingEnabled`, `VirtualizingPanel.CacheLength`, `VirtualizingPanel.CacheLengthUnit`을 추가해 가상화 리스트 동작을 더 보수적으로 조정했다. +- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs) +- [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs) +- [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs) + - `AgentLoopService`에 몰려 있던 검증 gate, 문서 fallback/후속 실행, post-compaction 정책 메서드를 partial 단위로 분리했다. + - 결과적으로 loop 본문은 정책 소비자에 더 가까워졌고, 향후 `claw-code`식 executor/verification/fallback 분해를 더 안전하게 진행할 수 있는 구조가 됐다. +- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs) + - 루프 finally 단계에서 `run_summary` 성능 로그를 남기도록 보강했다. + - iteration 수, tool 호출 수, 토큰 사용량, post-compaction suppression 수치가 함께 기록돼 사내 모델에서 `느린데 왜 느린지`를 나중에 역추적할 수 있다. diff --git a/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs b/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs new file mode 100644 index 0000000..b882c7c --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs @@ -0,0 +1,116 @@ +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState) + { + runState.PendingPostCompactionTurn = true; + runState.PostCompactionTurnCounter = 0; + runState.LastCompactionStageSummary = result.StageSummary; + runState.LastCompactionSavedTokens = result.SavedTokens; + SyncRunPostCompactionState(runState); + } + + private void NotifyPostCompactionTurnIfNeeded(RunState runState) + { + if (!runState.PendingPostCompactionTurn) + return; + + runState.PendingPostCompactionTurn = false; + runState.PostCompactionTurnCounter++; + SyncRunPostCompactionState(runState); + + var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary) + ? "기본" + : runState.LastCompactionStageSummary; + var saved = runState.LastCompactionSavedTokens > 0 + ? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감" + : ""; + EmitEvent( + AgentEventType.Thinking, + "", + $"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}"); + } + + private void SyncRunPostCompactionState(RunState runState) + { + _runPendingPostCompactionTurn = runState.PendingPostCompactionTurn; + _runPostCompactionTurnCounter = runState.PostCompactionTurnCounter; + _runLastCompactionStageSummary = runState.LastCompactionStageSummary ?? ""; + _runLastCompactionSavedTokens = runState.LastCompactionSavedTokens; + _runPostCompactionToolResultCompactions = runState.PostCompactionToolResultCompactions; + } + + private bool ShouldSuppressPostCompactionThinking(string summary) + { + if (string.IsNullOrWhiteSpace(summary)) + return false; + + var postCompactActive = _runPendingPostCompactionTurn || _runPostCompactionTurnCounter > 0; + if (!postCompactActive) + return false; + + if (_runPostCompactionTurnCounter > 1) + return false; + + return summary.StartsWith("LLM에 요청 중", StringComparison.Ordinal) + || summary.StartsWith("사용자 프롬프트 제출", StringComparison.Ordinal) + || summary.StartsWith("무료 티어 모드", StringComparison.Ordinal) + || summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal); + } + + private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState) + { + var output = result.Output ?? ""; + if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output)) + return TruncateOutput(output, 4000); + + runState.PostCompactionToolResultCompactions++; + SyncRunPostCompactionState(runState); + + var compacted = CompactToolResultForPostCompaction(call.ToolName, output); + var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary) + ? "compact" + : runState.LastCompactionStageSummary; + return $"[POST_COMPACTION_TOOL_RESULT:{call.ToolName}] {stage}\n{compacted}"; + } + + private static bool ShouldCompactToolResultForPostCompactionTurn(RunState runState, string toolName, string output) + { + if (runState.PostCompactionTurnCounter != 1) + return false; + if (string.IsNullOrWhiteSpace(output)) + return false; + if (output.Length < 900) + return false; + + return toolName is "process" or "build_run" or "test_loop" or "snippet_runner" or "git_tool" or "http_tool" + || output.Length > 1800; + } + + private static string CompactToolResultForPostCompaction(string toolName, string output) + { + var normalized = output.Replace("\r\n", "\n"); + var lines = normalized.Split('\n', StringSplitOptions.None) + .Select(line => line.TrimEnd()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + if (lines.Count == 0) + return TruncateOutput(output, 1200); + + var headCount = toolName is "build_run" or "test_loop" or "process" ? 6 : 4; + var tailCount = toolName is "build_run" or "test_loop" or "process" ? 4 : 3; + var kept = new List(); + kept.AddRange(lines.Take(headCount)); + + if (lines.Count > headCount + tailCount) + kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]..."); + + if (lines.Count > headCount) + kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount))); + + var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal)); + return TruncateOutput(compacted, 1400); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index e7b59fa..57fdb23 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -156,6 +156,7 @@ public partial class AgentLoopService { if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다."); + var runStopwatch = Stopwatch.StartNew(); IsRunning = true; _currentRunId = Guid.NewGuid().ToString("N"); _docFallbackAttempted = false; @@ -1741,6 +1742,30 @@ public partial class AgentLoopService EmitEvent(AgentEventType.StepDone, "total_stats", summary); } } + + runStopwatch.Stop(); + AgentPerformanceLogService.LogMetric( + "agent_loop", + "run_summary", + _conversationId, + ActiveTab ?? "", + runStopwatch.ElapsedMilliseconds, + new + { + runId = _currentRunId, + iterations = iteration, + toolCalls = totalToolCalls, + toolSuccess = statsSuccessCount, + toolFail = statsFailCount, + inputTokens = statsInputTokens, + outputTokens = statsOutputTokens, + repeatedFailureBlocks = statsRepeatedFailureBlocks, + recoveredAfterFailure = statsRecoveredAfterFailure, + postCompactionNoiseSuppressed = _runPostCompactionSuppressedThinkingCount, + postCompactionToolResultCompactions = _runPostCompactionToolResultCompactions, + taskType = taskPolicy.TaskType, + model = _settings.Settings.Llm.Model ?? "", + }); } } @@ -4619,118 +4644,6 @@ public partial class AgentLoopService } } - private void MarkRunPostCompaction(ContextCompactionResult result, RunState runState) - { - runState.PendingPostCompactionTurn = true; - runState.PostCompactionTurnCounter = 0; - runState.LastCompactionStageSummary = result.StageSummary; - runState.LastCompactionSavedTokens = result.SavedTokens; - SyncRunPostCompactionState(runState); - } - - private void NotifyPostCompactionTurnIfNeeded(RunState runState) - { - if (!runState.PendingPostCompactionTurn) - return; - - runState.PendingPostCompactionTurn = false; - runState.PostCompactionTurnCounter++; - SyncRunPostCompactionState(runState); - - var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary) - ? "기본" - : runState.LastCompactionStageSummary; - var saved = runState.LastCompactionSavedTokens > 0 - ? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감" - : ""; - EmitEvent( - AgentEventType.Thinking, - "", - $"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}"); - } - - private void SyncRunPostCompactionState(RunState runState) - { - _runPendingPostCompactionTurn = runState.PendingPostCompactionTurn; - _runPostCompactionTurnCounter = runState.PostCompactionTurnCounter; - _runLastCompactionStageSummary = runState.LastCompactionStageSummary ?? ""; - _runLastCompactionSavedTokens = runState.LastCompactionSavedTokens; - _runPostCompactionToolResultCompactions = runState.PostCompactionToolResultCompactions; - } - - private bool ShouldSuppressPostCompactionThinking(string summary) - { - if (string.IsNullOrWhiteSpace(summary)) - return false; - - var postCompactActive = _runPendingPostCompactionTurn || _runPostCompactionTurnCounter > 0; - if (!postCompactActive) - return false; - - if (_runPostCompactionTurnCounter > 1) - return false; - - return summary.StartsWith("LLM에 요청 중", StringComparison.Ordinal) - || summary.StartsWith("사용자 프롬프트 제출", StringComparison.Ordinal) - || summary.StartsWith("무료 티어 모드", StringComparison.Ordinal) - || summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal); - } - - private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState) - { - var output = result.Output ?? ""; - if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output)) - return TruncateOutput(output, 4000); - - runState.PostCompactionToolResultCompactions++; - SyncRunPostCompactionState(runState); - - var compacted = CompactToolResultForPostCompaction(call.ToolName, output); - var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary) - ? "compact" - : runState.LastCompactionStageSummary; - return $"[POST_COMPACTION_TOOL_RESULT:{call.ToolName}] {stage}\n{compacted}"; - } - - private static bool ShouldCompactToolResultForPostCompactionTurn(RunState runState, string toolName, string output) - { - if (runState.PostCompactionTurnCounter != 1) - return false; - if (string.IsNullOrWhiteSpace(output)) - return false; - if (output.Length < 900) - return false; - - return toolName is "process" or "build_run" or "test_loop" or "snippet_runner" or "git_tool" or "http_tool" - || output.Length > 1800; - } - - private static string CompactToolResultForPostCompaction(string toolName, string output) - { - var normalized = output.Replace("\r\n", "\n"); - var lines = normalized.Split('\n', StringSplitOptions.None) - .Select(line => line.TrimEnd()) - .Where(line => !string.IsNullOrWhiteSpace(line)) - .ToList(); - - if (lines.Count == 0) - return TruncateOutput(output, 1200); - - var headCount = toolName is "build_run" or "test_loop" or "process" ? 6 : 4; - var tailCount = toolName is "build_run" or "test_loop" or "process" ? 4 : 3; - var kept = new List(); - kept.AddRange(lines.Take(headCount)); - - if (lines.Count > headCount + tailCount) - kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]..."); - - if (lines.Count > headCount) - kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount))); - - var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal)); - return TruncateOutput(compacted, 1400); - } - /// 영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null. private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context) { diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs new file mode 100644 index 0000000..e0920fd --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs @@ -0,0 +1,156 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + private void ApplyDocumentPlanSuccessTransitions( + LlmService.ContentBlock call, + ToolResult result, + List messages, + ref bool documentPlanCalled, + ref string? documentPlanPath, + ref string? documentPlanTitle, + ref string? documentPlanScaffold) + { + if (!string.Equals(call.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase)) + return; + + documentPlanCalled = true; + var po = result.Output ?? string.Empty; + var pm = System.Text.RegularExpressions.Regex.Match(po, @"path:\s*""([^""]+)"""); + if (pm.Success) documentPlanPath = pm.Groups[1].Value; + var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)"""); + if (tm.Success) documentPlanTitle = tm.Groups[1].Value; + documentPlanScaffold = ExtractDocumentPlanScaffold(po); + + if (!ContainsDocumentPlanFollowUpInstruction(po)) + return; + + var toolHint = ResolveDocumentPlanFollowUpTool(po); + messages.Add(new ChatMessage + { + Role = "user", + Content = + "document_plan이 완료되었습니다. " + + "방금 생성된 골격의 [내용...] 자리와 각 섹션 내용을 실제 상세 본문으로 모두 채운 뒤 " + + $"{toolHint} 도구를 지금 즉시 호출하세요. " + + "설명만 하지 말고 실제 문서 생성 도구 호출로 바로 이어가세요." + }); + EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완료 · {toolHint} 실행 유도"); + } + + private static string? ExtractDocumentPlanScaffold(string output) + { + if (string.IsNullOrWhiteSpace(output)) + return null; + + var markers = new (string Start, string End)[] + { + ("--- body 시작 ---", "--- body 끝 ---"), + ("--- body start ---", "--- body end ---"), + ("", ""), + }; + + foreach (var (startMarker, endMarker) in markers) + { + var start = output.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase); + if (start < 0) + continue; + + var contentStart = start + startMarker.Length; + var end = output.IndexOf(endMarker, contentStart, StringComparison.OrdinalIgnoreCase); + if (end <= contentStart) + continue; + + var scaffold = output[contentStart..end].Trim(); + if (!string.IsNullOrWhiteSpace(scaffold)) + return scaffold; + } + + return null; + } + + private static bool ContainsDocumentPlanFollowUpInstruction(string output) + { + if (string.IsNullOrWhiteSpace(output)) + return false; + + return output.Contains("즉시 실행", StringComparison.OrdinalIgnoreCase) + || output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase) + || output.Contains("call html_create", StringComparison.OrdinalIgnoreCase) + || output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveDocumentPlanFollowUpTool(string output) + { + if (output.Contains("document_assemble", StringComparison.OrdinalIgnoreCase)) + return "document_assemble"; + if (output.Contains("docx_create", StringComparison.OrdinalIgnoreCase)) + return "docx_create"; + if (output.Contains("markdown_create", StringComparison.OrdinalIgnoreCase)) + return "markdown_create"; + if (output.Contains("file_write", StringComparison.OrdinalIgnoreCase)) + return "file_write"; + return "html_create"; + } + + private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync( + LlmService.ContentBlock call, + ToolResult result, + List toolCalls, + List messages, + Models.LlmSettings llm, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, + AgentContext context, + CancellationToken ct) + { + if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1) + return (false, false); + + var verificationEnabled = executionPolicy.EnablePostToolVerification + && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); + var shouldVerify = ShouldRunPostToolVerification( + ActiveTab, + call.ToolName, + result.Success, + verificationEnabled, + verificationEnabled); + var consumedExtraIteration = false; + if (shouldVerify) + { + await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); + consumedExtraIteration = true; + } + + EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); + return (true, consumedExtraIteration); + } + + private async Task TryApplyPostToolVerificationTransitionAsync( + LlmService.ContentBlock call, + ToolResult result, + List messages, + Models.LlmSettings llm, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, + AgentContext context, + CancellationToken ct) + { + if (!result.Success) + return false; + + var verificationEnabled = executionPolicy.EnablePostToolVerification + && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); + var shouldVerify = ShouldRunPostToolVerification( + ActiveTab, + call.ToolName, + result.Success, + verificationEnabled, + verificationEnabled); + if (!shouldVerify) + return false; + + await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); + return true; + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 0b6b7f0..0079510 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -58,205 +58,6 @@ public partial class AgentLoopService return true; } - private bool TryApplyCodeCompletionGateTransition( - List messages, - string? textResponse, - TaskTypePolicy taskPolicy, - bool requireHighImpactCodeVerification, - int totalToolCalls, - RunState runState, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) - { - if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0) - return false; - - var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( - messages, - requireHighImpactCodeVerification); - if (executionPolicy.CodeVerificationGateMaxRetries > 0 - && !hasCodeVerificationEvidence - && runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries) - { - runState.CodeVerificationGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = requireHighImpactCodeVerification - ? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요." - : "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요." - }); - EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification - ? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..." - : "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다..."); - return true; - } - - if (requireHighImpactCodeVerification - && executionPolicy.HighImpactBuildTestGateMaxRetries > 0 - && !HasSuccessfulBuildAndTestAfterLastModification(messages) - && runState.HighImpactBuildTestGateRetry < executionPolicy.HighImpactBuildTestGateMaxRetries) - { - runState.HighImpactBuildTestGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요." - }); - EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다..."); - return true; - } - - if (executionPolicy.FinalReportGateMaxRetries > 0 - && !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages) - && runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries) - { - runState.FinalReportGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification) - }); - EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다..."); - return true; - } - - return false; - } - - private bool TryApplyCodeDiffEvidenceGateTransition( - List messages, - string? textResponse, - RunState runState, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) - { - if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) - return false; - - if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries) - return false; - - if (HasDiffEvidenceAfterLastModification(messages)) - return false; - - runState.CodeDiffGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. " + - "git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. " + - "지금 즉시 git_tool 도구를 호출하세요." - }); - EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다..."); - return true; - } - - private bool TryApplyRecentExecutionEvidenceGateTransition( - List messages, - string? textResponse, - TaskTypePolicy taskPolicy, - RunState runState, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) - { - if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) - return false; - - if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries) - return false; - - if (!HasAnyBuildOrTestEvidence(messages)) - return false; - - if (HasBuildOrTestEvidenceAfterLastModification(messages)) - return false; - - runState.RecentExecutionGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = BuildRecentExecutionEvidencePrompt(taskPolicy) - }); - EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다..."); - return true; - } - - private bool TryApplyExecutionSuccessGateTransition( - List messages, - string? textResponse, - TaskTypePolicy taskPolicy, - RunState runState, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) - { - if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) - return false; - - if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries) - return false; - - if (!HasAnyBuildOrTestAttempt(messages)) - return false; - - if (HasAnyBuildOrTestEvidence(messages)) - return false; - - runState.ExecutionSuccessGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = BuildExecutionSuccessGatePrompt(taskPolicy) - }); - EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다..."); - return true; - } - - private bool TryApplyTerminalEvidenceGateTransition( - List messages, - string? textResponse, - TaskTypePolicy taskPolicy, - string userQuery, - int totalToolCalls, - string? lastArtifactFilePath, - RunState runState, - int retryMax) - { - if (totalToolCalls <= 0) - return false; - - if (ShouldSkipTerminalEvidenceGateForAnalysisQuery(userQuery, taskPolicy)) - return false; - - if (runState.TerminalEvidenceGateRetry >= retryMax) - return false; - - if (HasTerminalProgressEvidence(taskPolicy, messages, lastArtifactFilePath)) - return false; - - runState.TerminalEvidenceGateRetry++; - if (!string.IsNullOrEmpty(textResponse)) - messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); - messages.Add(new ChatMessage - { - Role = "user", - Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath) - }); - EmitEvent( - AgentEventType.Thinking, - "", - $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})"); - return true; - } - private static bool ShouldSkipTerminalEvidenceGateForAnalysisQuery(string userQuery, TaskTypePolicy taskPolicy) { if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)) @@ -1318,189 +1119,6 @@ public partial class AgentLoopService || lower.Contains("json parse"); } - private void ApplyDocumentPlanSuccessTransitions( - LlmService.ContentBlock call, - ToolResult result, - List messages, - ref bool documentPlanCalled, - ref string? documentPlanPath, - ref string? documentPlanTitle, - ref string? documentPlanScaffold) - { - if (!string.Equals(call.ToolName, "document_plan", StringComparison.OrdinalIgnoreCase)) - return; - - documentPlanCalled = true; - var po = result.Output ?? string.Empty; - var pm = System.Text.RegularExpressions.Regex.Match(po, @"path:\s*""([^""]+)"""); - if (pm.Success) documentPlanPath = pm.Groups[1].Value; - var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)"""); - if (tm.Success) documentPlanTitle = tm.Groups[1].Value; - documentPlanScaffold = ExtractDocumentPlanScaffold(po); - - if (!ContainsDocumentPlanFollowUpInstruction(po)) - return; - - var toolHint = ResolveDocumentPlanFollowUpTool(po); - messages.Add(new ChatMessage - { - Role = "user", - Content = - "document_plan이 완료되었습니다. " + - "방금 생성된 골격의 [내용...] 자리와 각 섹션 내용을 실제 상세 본문으로 모두 채운 뒤 " + - $"{toolHint} 도구를 지금 즉시 호출하세요. " + - "설명만 하지 말고 실제 문서 생성 도구 호출로 바로 이어가세요." - }); - EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완료 · {toolHint} 실행 유도"); - } - - private static string? ExtractDocumentPlanScaffold(string output) - { - if (string.IsNullOrWhiteSpace(output)) - return null; - - var markers = new (string Start, string End)[] - { - ("--- body 시작 ---", "--- body 끝 ---"), - ("--- body start ---", "--- body end ---"), - ("", ""), - }; - - foreach (var (startMarker, endMarker) in markers) - { - var start = output.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase); - if (start < 0) - continue; - - var contentStart = start + startMarker.Length; - var end = output.IndexOf(endMarker, contentStart, StringComparison.OrdinalIgnoreCase); - if (end <= contentStart) - continue; - - var scaffold = output[contentStart..end].Trim(); - if (!string.IsNullOrWhiteSpace(scaffold)) - return scaffold; - } - - return null; - } - - private static bool ContainsDocumentPlanFollowUpInstruction(string output) - { - if (string.IsNullOrWhiteSpace(output)) - return false; - - return output.Contains("즉시 실행", StringComparison.OrdinalIgnoreCase) - || output.Contains("immediate next step", StringComparison.OrdinalIgnoreCase) - || output.Contains("call html_create", StringComparison.OrdinalIgnoreCase) - || output.Contains("call document_assemble", StringComparison.OrdinalIgnoreCase); - } - - private static string ResolveDocumentPlanFollowUpTool(string output) - { - if (output.Contains("document_assemble", StringComparison.OrdinalIgnoreCase)) - return "document_assemble"; - if (output.Contains("docx_create", StringComparison.OrdinalIgnoreCase)) - return "docx_create"; - if (output.Contains("markdown_create", StringComparison.OrdinalIgnoreCase)) - return "markdown_create"; - if (output.Contains("file_write", StringComparison.OrdinalIgnoreCase)) - return "file_write"; - return "html_create"; - } - - private void ApplyCodeQualityFollowUpTransition( - LlmService.ContentBlock call, - ToolResult result, - List messages, - TaskTypePolicy taskPolicy, - ref bool requireHighImpactCodeVerification, - ref string? lastModifiedCodeFilePath) - { - var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); - if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result)) - { - requireHighImpactCodeVerification = highImpactCodeChange; - lastModifiedCodeFilePath = result.FilePath; - messages.Add(new ChatMessage - { - Role = "user", - Content = BuildCodeQualityFollowUpPrompt( - call.ToolName, - result, - highImpactCodeChange, - HasAnyBuildOrTestEvidence(messages), - taskPolicy) - }); - EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange - ? "怨좎쁺??肄붾뱶 蹂€寃쎌쑝濡?李몄“ 寃€利앷낵 build/test 寃€利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??." - : "肄붾뱶 蹂€寃???build/test/diff 寃€利앹쓣 ?댁뼱媛묐땲??."); - } - else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification)) - { - requireHighImpactCodeVerification = false; - } - } - - private async Task<(bool Completed, bool ConsumedExtraIteration)> TryHandleTerminalDocumentCompletionTransitionAsync( - LlmService.ContentBlock call, - ToolResult result, - List toolCalls, - List messages, - Models.LlmSettings llm, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, - AgentContext context, - CancellationToken ct) - { - if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1) - return (false, false); - - var verificationEnabled = executionPolicy.EnablePostToolVerification - && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); - var shouldVerify = ShouldRunPostToolVerification( - ActiveTab, - call.ToolName, - result.Success, - verificationEnabled, - verificationEnabled); - var consumedExtraIteration = false; - if (shouldVerify) - { - await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); - consumedExtraIteration = true; - } - - EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); - return (true, consumedExtraIteration); - } - - private async Task TryApplyPostToolVerificationTransitionAsync( - LlmService.ContentBlock call, - ToolResult result, - List messages, - Models.LlmSettings llm, - ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy, - AgentContext context, - CancellationToken ct) - { - if (!result.Success) - return false; - - var verificationEnabled = executionPolicy.EnablePostToolVerification - && AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm); - var shouldVerify = ShouldRunPostToolVerification( - ActiveTab, - call.ToolName, - result.Success, - verificationEnabled, - verificationEnabled); - if (!shouldVerify) - return false; - - await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct); - return true; - } - private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync( LlmService.ContentBlock call, AgentContext context, diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs new file mode 100644 index 0000000..c561c4c --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs @@ -0,0 +1,233 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + private void ApplyCodeQualityFollowUpTransition( + LlmService.ContentBlock call, + ToolResult result, + List messages, + TaskTypePolicy taskPolicy, + ref bool requireHighImpactCodeVerification, + ref string? lastModifiedCodeFilePath) + { + var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result); + if (ShouldInjectCodeQualityFollowUp(ActiveTab ?? "", call.ToolName, result)) + { + requireHighImpactCodeVerification = highImpactCodeChange; + lastModifiedCodeFilePath = result.FilePath; + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildCodeQualityFollowUpPrompt( + call.ToolName, + result, + highImpactCodeChange, + HasAnyBuildOrTestEvidence(messages), + taskPolicy) + }); + EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange + ? "고영향 코드 변경으로 분류돼 참조 검증과 build/test 검증을 더 엄격하게 이어갑니다." + : "코드 변경 후 build/test/diff 검증을 이어갑니다."); + } + else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification)) + { + requireHighImpactCodeVerification = false; + } + } + + private bool TryApplyCodeCompletionGateTransition( + List messages, + string? textResponse, + TaskTypePolicy taskPolicy, + bool requireHighImpactCodeVerification, + int totalToolCalls, + RunState runState, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0) + return false; + + var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification( + messages, + requireHighImpactCodeVerification); + if (executionPolicy.CodeVerificationGateMaxRetries > 0 + && !hasCodeVerificationEvidence + && runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries) + { + runState.CodeVerificationGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = requireHighImpactCodeVerification + ? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요." + : "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요." + }); + EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification + ? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..." + : "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다..."); + return true; + } + + if (requireHighImpactCodeVerification + && executionPolicy.HighImpactBuildTestGateMaxRetries > 0 + && !HasSuccessfulBuildAndTestAfterLastModification(messages) + && runState.HighImpactBuildTestGateRetry < executionPolicy.HighImpactBuildTestGateMaxRetries) + { + runState.HighImpactBuildTestGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요." + }); + EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다..."); + return true; + } + + if (executionPolicy.FinalReportGateMaxRetries > 0 + && !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages) + && runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries) + { + runState.FinalReportGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification) + }); + EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다..."); + return true; + } + + return false; + } + + private bool TryApplyCodeDiffEvidenceGateTransition( + List messages, + string? textResponse, + RunState runState, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return false; + + if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries) + return false; + + if (HasDiffEvidenceAfterLastModification(messages)) + return false; + + runState.CodeDiffGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. 지금 즉시 git_tool 도구를 호출하세요." + }); + EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다..."); + return true; + } + + private bool TryApplyRecentExecutionEvidenceGateTransition( + List messages, + string? textResponse, + TaskTypePolicy taskPolicy, + RunState runState, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return false; + + if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries) + return false; + + if (!HasAnyBuildOrTestEvidence(messages)) + return false; + + if (HasBuildOrTestEvidenceAfterLastModification(messages)) + return false; + + runState.RecentExecutionGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildRecentExecutionEvidencePrompt(taskPolicy) + }); + EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다..."); + return true; + } + + private bool TryApplyExecutionSuccessGateTransition( + List messages, + string? textResponse, + TaskTypePolicy taskPolicy, + RunState runState, + ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return false; + + if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries) + return false; + + if (!HasAnyBuildOrTestAttempt(messages)) + return false; + + if (HasAnyBuildOrTestEvidence(messages)) + return false; + + runState.ExecutionSuccessGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildExecutionSuccessGatePrompt(taskPolicy) + }); + EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다..."); + return true; + } + + private bool TryApplyTerminalEvidenceGateTransition( + List messages, + string? textResponse, + TaskTypePolicy taskPolicy, + string userQuery, + int totalToolCalls, + string? lastArtifactFilePath, + RunState runState, + int retryMax) + { + if (totalToolCalls <= 0) + return false; + + if (ShouldSkipTerminalEvidenceGateForAnalysisQuery(userQuery, taskPolicy)) + return false; + + if (runState.TerminalEvidenceGateRetry >= retryMax) + return false; + + if (HasTerminalProgressEvidence(taskPolicy, messages, lastArtifactFilePath)) + return false; + + runState.TerminalEvidenceGateRetry++; + if (!string.IsNullOrEmpty(textResponse)) + messages.Add(new ChatMessage { Role = "assistant", Content = textResponse }); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildTerminalEvidenceGatePrompt(taskPolicy, lastArtifactFilePath) + }); + EmitEvent(AgentEventType.Thinking, "", $"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})"); + return true; + } +} diff --git a/src/AxCopilot/Services/AgentPerformanceLogService.cs b/src/AxCopilot/Services/AgentPerformanceLogService.cs new file mode 100644 index 0000000..793bae9 --- /dev/null +++ b/src/AxCopilot/Services/AgentPerformanceLogService.cs @@ -0,0 +1,72 @@ +using System.Text; +using System.Text.Json; +using System.IO; + +namespace AxCopilot.Services; + +public static class AgentPerformanceLogService +{ + private static readonly string PerfDir; + private static readonly object _lock = new(); + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = false, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + static AgentPerformanceLogService() + { + PerfDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", + "perf"); + try { Directory.CreateDirectory(PerfDir); } catch { } + } + + public static void LogMetric( + string area, + string name, + string conversationId, + string tab, + long durationMs, + object? detail = null) + { + try + { + var fileName = $"performance-{DateTime.Now:yyyy-MM-dd}.json"; + var filePath = Path.Combine(PerfDir, fileName); + var json = JsonSerializer.Serialize(new AgentPerformanceEntry + { + Timestamp = DateTime.Now, + Area = area, + Name = name, + ConversationId = conversationId ?? "", + Tab = tab ?? "", + DurationMs = durationMs, + Detail = detail == null ? "" : JsonSerializer.Serialize(detail, _jsonOptions), + }, _jsonOptions); + + lock (_lock) + { + File.AppendAllText(filePath, json + Environment.NewLine, Encoding.UTF8); + } + } + catch + { + // 성능 로그 실패는 런타임에 영향 주지 않음 + } + } + + public static string GetPerformanceFolder() => PerfDir; +} + +public sealed class AgentPerformanceEntry +{ + public DateTime Timestamp { get; init; } + public string Area { get; init; } = ""; + public string Name { get; init; } = ""; + public string ConversationId { get; init; } = ""; + public string Tab { get; init; } = ""; + public long DurationMs { get; init; } + public string Detail { get; init; } = ""; +} diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs index 91c57b9..8fa7364 100644 --- a/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Windows.Threading; using AxCopilot.Models; +using AxCopilot.Services; namespace AxCopilot.Views; @@ -18,6 +20,7 @@ public partial class ChatWindow private void RenderMessages(bool preserveViewport = false) { + var renderStopwatch = Stopwatch.StartNew(); var previousScrollableHeight = GetTranscriptScrollableHeight(); var previousVerticalOffset = GetTranscriptVerticalOffset(); @@ -62,10 +65,32 @@ public partial class ChatWindow if (!TryApplyIncrementalTranscriptRender(renderPlan)) ApplyFullTranscriptRender(renderPlan); + PruneTranscriptElementCache(renderPlan.NewKeys); _lastRenderedMessageCount = visibleMessages.Count; _lastRenderedEventCount = visibleEvents.Count; _lastRenderedShowHistory = renderPlan.ShowHistory; + renderStopwatch.Stop(); + if (renderStopwatch.ElapsedMilliseconds >= 24 || _isStreaming) + { + AgentPerformanceLogService.LogMetric( + "transcript", + "render_messages", + conv.Id, + _activeTab ?? "", + renderStopwatch.ElapsedMilliseconds, + new + { + preserveViewport, + streaming = _isStreaming, + lightweight = IsLightweightLiveProgressMode(), + visibleMessages = visibleMessages.Count, + visibleEvents = visibleEvents.Count, + renderedItems = renderPlan.NewKeys.Count, + hiddenCount = renderPlan.HiddenCount, + transcriptElements = GetTranscriptElementCount(), + }); + } if (!preserveViewport) { diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs b/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs new file mode 100644 index 0000000..46045a8 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs @@ -0,0 +1,25 @@ +using System.Linq; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private const int TranscriptCacheRetentionCount = 240; + + private void PruneTranscriptElementCache(IReadOnlyCollection visibleKeys) + { + if (_elementCache.Count <= TranscriptCacheRetentionCount) + return; + + var keep = new HashSet(visibleKeys, StringComparer.Ordinal); + foreach (var key in _lastRenderedTimelineKeys.TakeLast(Math.Min(TranscriptCacheRetentionCount, _lastRenderedTimelineKeys.Count))) + keep.Add(key); + + var removable = _elementCache.Keys + .Where(key => !keep.Contains(key)) + .ToList(); + + foreach (var key in removable) + _elementCache.Remove(key); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 2154217..b755fb2 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -1366,9 +1366,12 @@ ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.CanContentScroll="True" + ScrollViewer.IsDeferredScrollingEnabled="True" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" VirtualizingPanel.ScrollUnit="Pixel" + VirtualizingPanel.CacheLength="2" + VirtualizingPanel.CacheLengthUnit="Item" UseLayoutRounding="True">