AX Agent loop 정책을 분리하고 transcript 가상화·성능 계측 구조를 강화한다
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- 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개
This commit is contained in:
@@ -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 보존을 맡기도록 정리했습니다.
|
- 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 생성 -> 증분/전체 적용`만 오케스트레이션하는 얇은 진입점이 됐습니다.
|
- [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 조작을 분리하는 방향에 더 가까워졌고, 이후 실제 가상화 윈도우 정책을 다듬을 때 변경 범위를 더 안전하게 제한할 수 있게 됐습니다.
|
- `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 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다.
|
||||||
|
|||||||
@@ -5536,3 +5536,25 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
|||||||
- 구조 효과
|
- 구조 효과
|
||||||
- transcript 렌더의 변경 포인트가 `계획 생성`과 `실행 적용`으로 명확히 나뉘었다.
|
- transcript 렌더의 변경 포인트가 `계획 생성`과 `실행 적용`으로 명확히 나뉘었다.
|
||||||
- 향후 실제 가상화 강화, render batching, incremental diff 정교화 작업을 더 안전하게 진행할 수 있다.
|
- 향후 실제 가상화 강화, 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 수치가 함께 기록돼 사내 모델에서 `느린데 왜 느린지`를 나중에 역추적할 수 있다.
|
||||||
|
|||||||
116
src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs
Normal file
116
src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs
Normal file
@@ -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<string>();
|
||||||
|
kept.AddRange(lines.Take(headCount));
|
||||||
|
|
||||||
|
if (lines.Count > headCount + tailCount)
|
||||||
|
kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]...");
|
||||||
|
|
||||||
|
if (lines.Count > headCount)
|
||||||
|
kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount)));
|
||||||
|
|
||||||
|
var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal));
|
||||||
|
return TruncateOutput(compacted, 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,7 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
|
||||||
|
|
||||||
|
var runStopwatch = Stopwatch.StartNew();
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
_currentRunId = Guid.NewGuid().ToString("N");
|
_currentRunId = Guid.NewGuid().ToString("N");
|
||||||
_docFallbackAttempted = false;
|
_docFallbackAttempted = false;
|
||||||
@@ -1741,6 +1742,30 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
|
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<string>();
|
|
||||||
kept.AddRange(lines.Take(headCount));
|
|
||||||
|
|
||||||
if (lines.Count > headCount + tailCount)
|
|
||||||
kept.Add($"...[post-compact snip: {lines.Count - headCount - tailCount:N0}줄 생략]...");
|
|
||||||
|
|
||||||
if (lines.Count > headCount)
|
|
||||||
kept.AddRange(lines.Skip(Math.Max(headCount, lines.Count - tailCount)));
|
|
||||||
|
|
||||||
var compacted = string.Join("\n", kept.Distinct(StringComparer.Ordinal));
|
|
||||||
return TruncateOutput(compacted, 1400);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
|
/// <summary>영향 범위 기반 의사결정 체크. 확인이 필요하면 메시지를 반환, 불필요하면 null.</summary>
|
||||||
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
|
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
|
||||||
{
|
{
|
||||||
|
|||||||
156
src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs
Normal file
156
src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using AxCopilot.Models;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
public partial class AgentLoopService
|
||||||
|
{
|
||||||
|
private void ApplyDocumentPlanSuccessTransitions(
|
||||||
|
LlmService.ContentBlock call,
|
||||||
|
ToolResult result,
|
||||||
|
List<ChatMessage> 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 ---"),
|
||||||
|
("<!-- body start marker -->", "<!-- body end marker -->"),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<LlmService.ContentBlock> toolCalls,
|
||||||
|
List<ChatMessage> 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<bool> TryApplyPostToolVerificationTransitionAsync(
|
||||||
|
LlmService.ContentBlock call,
|
||||||
|
ToolResult result,
|
||||||
|
List<ChatMessage> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,205 +58,6 @@ public partial class AgentLoopService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryApplyCodeCompletionGateTransition(
|
|
||||||
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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)
|
private static bool ShouldSkipTerminalEvidenceGateForAnalysisQuery(string userQuery, TaskTypePolicy taskPolicy)
|
||||||
{
|
{
|
||||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -1318,189 +1119,6 @@ public partial class AgentLoopService
|
|||||||
|| lower.Contains("json parse");
|
|| lower.Contains("json parse");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyDocumentPlanSuccessTransitions(
|
|
||||||
LlmService.ContentBlock call,
|
|
||||||
ToolResult result,
|
|
||||||
List<ChatMessage> 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 ---"),
|
|
||||||
("<!-- body start marker -->", "<!-- body end marker -->"),
|
|
||||||
};
|
|
||||||
|
|
||||||
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<ChatMessage> 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<LlmService.ContentBlock> toolCalls,
|
|
||||||
List<ChatMessage> 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<bool> TryApplyPostToolVerificationTransitionAsync(
|
|
||||||
LlmService.ContentBlock call,
|
|
||||||
ToolResult result,
|
|
||||||
List<ChatMessage> 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(
|
private async Task<(bool ShouldContinue, string? TerminalResponse)> TryHandleUserDecisionTransitionsAsync(
|
||||||
LlmService.ContentBlock call,
|
LlmService.ContentBlock call,
|
||||||
AgentContext context,
|
AgentContext context,
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using AxCopilot.Models;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
public partial class AgentLoopService
|
||||||
|
{
|
||||||
|
private void ApplyCodeQualityFollowUpTransition(
|
||||||
|
LlmService.ContentBlock call,
|
||||||
|
ToolResult result,
|
||||||
|
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/AxCopilot/Services/AgentPerformanceLogService.cs
Normal file
72
src/AxCopilot/Services/AgentPerformanceLogService.cs
Normal file
@@ -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; } = "";
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using AxCopilot.Models;
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
namespace AxCopilot.Views;
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ public partial class ChatWindow
|
|||||||
|
|
||||||
private void RenderMessages(bool preserveViewport = false)
|
private void RenderMessages(bool preserveViewport = false)
|
||||||
{
|
{
|
||||||
|
var renderStopwatch = Stopwatch.StartNew();
|
||||||
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
||||||
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
||||||
|
|
||||||
@@ -62,10 +65,32 @@ public partial class ChatWindow
|
|||||||
|
|
||||||
if (!TryApplyIncrementalTranscriptRender(renderPlan))
|
if (!TryApplyIncrementalTranscriptRender(renderPlan))
|
||||||
ApplyFullTranscriptRender(renderPlan);
|
ApplyFullTranscriptRender(renderPlan);
|
||||||
|
PruneTranscriptElementCache(renderPlan.NewKeys);
|
||||||
|
|
||||||
_lastRenderedMessageCount = visibleMessages.Count;
|
_lastRenderedMessageCount = visibleMessages.Count;
|
||||||
_lastRenderedEventCount = visibleEvents.Count;
|
_lastRenderedEventCount = visibleEvents.Count;
|
||||||
_lastRenderedShowHistory = renderPlan.ShowHistory;
|
_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)
|
if (!preserveViewport)
|
||||||
{
|
{
|
||||||
|
|||||||
25
src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs
Normal file
25
src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private const int TranscriptCacheRetentionCount = 240;
|
||||||
|
|
||||||
|
private void PruneTranscriptElementCache(IReadOnlyCollection<string> visibleKeys)
|
||||||
|
{
|
||||||
|
if (_elementCache.Count <= TranscriptCacheRetentionCount)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var keep = new HashSet<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1366,9 +1366,12 @@
|
|||||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
ScrollViewer.CanContentScroll="True"
|
ScrollViewer.CanContentScroll="True"
|
||||||
|
ScrollViewer.IsDeferredScrollingEnabled="True"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
VirtualizingPanel.ScrollUnit="Pixel"
|
VirtualizingPanel.ScrollUnit="Pixel"
|
||||||
|
VirtualizingPanel.CacheLength="2"
|
||||||
|
VirtualizingPanel.CacheLengthUnit="Item"
|
||||||
UseLayoutRounding="True">
|
UseLayoutRounding="True">
|
||||||
<ListBox.ItemsPanel>
|
<ListBox.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
|
|||||||
Reference in New Issue
Block a user