AX Agent loop 정책을 분리하고 transcript 가상화·성능 계측 구조를 강화한다
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:
2026-04-09 00:57:38 +09:00
parent 594d38e4a9
commit 0ceca202e9
11 changed files with 682 additions and 494 deletions

View File

@@ -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<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>
private string? CheckDecisionRequired(LlmService.ContentBlock call, AgentContext context)
{