에이전트 루프 수명주기와 tool_result 복원 품질을 마감한다

목적:
- AgentLoopService 시작/종료 책임을 분리해 루프 본체를 더 얇은 orchestration 구조로 정리한다.
- 장기 세션과 분기 대화에서 tool_result preview가 다른 tool_use_id로 재등장해도 안정적으로 복원되도록 replacement state를 보강한다.
- DOCX/PPTX/XLSX 반환 경로까지 문서 품질 출력 formatter를 통일해 포맷별 quality summary와 repair guide 표현을 일관되게 맞춘다.

핵심 수정:
- AgentLoopRunLifecycle.cs를 추가해 BeginRun/BootstrapRunAsync/FinalizeRun/ResetRunTransientState를 분리하고, AgentLoopService는 해당 helper를 사용하도록 정리했다.
- run 종료 metric에 빈 run id가 기록되던 흐름을 수정해 실제 run id가 유지되도록 고쳤다.
- AgentMessageInvariantHelper에 persisted preview map, fingerprint preview map, tool_use_id 재바인딩 로직을 추가해 저장/재개/분기 이후 preview 복원 품질을 높였다.
- ArtifactQualityOutputFormatter를 DocxSkill, PptxSkill, ExcelSkill 멀티시트 출력 경로까지 연결해 quality summary/repair guide 문자열 조립을 공통 helper로 통일했다.
- AgentMessageInvariantHelperTests에 fingerprint 재바인딩 회귀를 추가했다.
- README.md, docs/DEVELOPMENT.md, docs/NEXT_ROADMAP.md에 2026-04-15 10:05 (KST) 기준 변경 이력을 반영했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_run_finalize\\ -p:IntermediateOutputPath=obj\\verify_run_finalize\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentCommandQueueTests|AgentQueuedCommandProjectorTests|AgentMessageInvariantHelperTests|AgentQueryContextBuilderTests|ChatStorageServiceTests|PptxSkillGoldenDeckTests|ExcelSkillGoldenWorkbookTests|DocxSkillGoldenDocumentTests|HtmlSkillGoldenReportTests -p:OutputPath=bin\\verify_run_finalize_tests2\\ -p:IntermediateOutputPath=obj\\verify_run_finalize_tests2\\ : 통과 18
This commit is contained in:
2026-04-15 10:07:01 +09:00
parent bcb3cc4039
commit 8c0aa98408
11 changed files with 503 additions and 165 deletions

View File

@@ -233,34 +233,21 @@ public partial class AgentLoopService
{
if (IsRunning) throw new InvalidOperationException("에이전트가 이미 실행 중입니다.");
var runStopwatch = Stopwatch.StartNew();
IsRunning = true;
_currentRunId = Guid.NewGuid().ToString("N");
_docFallbackAttempted = false;
_documentPlanApproved = false;
_pendingCommands.Clear(); // 이전 실행의 잔여 큐 제거
var llm = _settings.Settings.Llm;
var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : 25;
var maxIterations = baseMax; // 동적 조정 가능
var maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : 3;
var iteration = 0;
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
// IntentGate: 통합 의도 분류
var intentGate = new IntentGateService(_llm);
var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false);
var explorationState = new ExplorationTrackingState
{
Scope = intentResult.SuggestedScope,
SelectiveHit = true,
};
var pathAccessState = new PathAccessTrackingState();
// P3: 누적 학습 — 도구 결과에서 자동 학습 포인트 수집
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings)
: null;
var bootstrap = BeginRun(messages);
var runStopwatch = bootstrap.RunStopwatch;
var userQuery = bootstrap.UserQuery;
var maxIterations = bootstrap.MaxIterations;
var baseMax = bootstrap.MaxIterations;
var maxRetry = bootstrap.MaxRetry;
var runBootstrap = await BootstrapRunAsync(messages, runStopwatch, userQuery, maxIterations, maxRetry, ct).ConfigureAwait(false);
maxIterations = runBootstrap.MaxIterations;
maxRetry = runBootstrap.MaxRetry;
var intentResult = runBootstrap.IntentResult;
var explorationState = runBootstrap.ExplorationState;
var pathAccessState = runBootstrap.PathAccessState;
var sessionLearnings = runBootstrap.SessionLearnings;
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
@@ -304,9 +291,8 @@ public partial class AgentLoopService
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
var statsModifiedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적
var taskType = intentResult.TaskType;
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = ExecutionPolicyMerger.Apply(
_llm.GetActiveExecutionPolicy(), intentResult.PolicyOverlay);
var taskPolicy = runBootstrap.TaskPolicy;
var executionPolicy = runBootstrap.ExecutionPolicy;
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
@@ -317,25 +303,8 @@ public partial class AgentLoopService
var runState = new RunState();
var requireHighImpactCodeVerification = false;
string? lastModifiedCodeFilePath = null;
maxRetry = ComputeAdaptiveMaxRetry(maxRetry, taskPolicy.TaskType);
var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType);
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
// P5: 복합 요청 감지 시 DecompositionHint 주입
if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint))
{
messages.Add(new ChatMessage
{
Role = "user",
Content = $"[System:DecompositionHint]\n{intentResult.DecompositionHint}\n" +
"Consider using spawn_agents to run independent sub-tasks in parallel.",
MetaKind = "decomposition_hint",
});
}
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
@@ -1869,112 +1838,23 @@ public partial class AgentLoopService
{
if (runtimeOverrideApplied)
_llm.PopInferenceOverride();
// 워크플로우 상세 로그: 에이전트 루프 종료
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "end",
summary: $"iterations={iteration}, tools={totalToolCalls}, success={statsSuccessCount}, fail={statsFailCount}");
IsRunning = false;
_currentRunId = "";
_runPendingPostCompactionTurn = false;
_runPostCompactionTurnCounter = 0;
_runPostCompactionSuppressedThinkingCount = 0;
_runLastCompactionStageSummary = "";
_runLastCompactionSavedTokens = 0;
_runPostCompactionToolResultCompactions = 0;
// 일시정지 상태 리셋
if (IsPaused)
{
IsPaused = false;
try { _pauseSemaphore.Release(); }
catch (SemaphoreFullException) { }
}
// 통계 기록 (도구 호출이 1회 이상인 세션만)
if (totalToolCalls > 0)
{
AgentPerformanceLogService.LogExplorationBreadth(
_conversationId,
ActiveTab,
new
{
scope = explorationState.Scope.ToString().ToLowerInvariant(),
folder_map_calls = explorationState.FolderMapCalls,
total_files_read = explorationState.TotalFilesRead,
multi_read_files = explorationState.MultiReadFilesRead,
broad_scan = explorationState.BroadScanDetected,
selective_hit = explorationState.SelectiveHit,
corrective_hint = explorationState.CorrectiveHintInjected
});
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
{
Timestamp = statsStart,
Tab = ActiveTab ?? "",
TaskType = taskPolicy.TaskType,
Model = _settings.Settings.Llm.Model ?? "",
ToolCalls = totalToolCalls,
SuccessCount = statsSuccessCount,
FailCount = statsFailCount,
InputTokens = statsInputTokens,
OutputTokens = statsOutputTokens,
DurationMs = durationMs,
RepeatedFailureBlockedCount = statsRepeatedFailureBlocks,
RecoveredAfterFailureCount = statsRecoveredAfterFailure,
UsedTools = statsUsedTools,
});
// 전체 호출·토큰 합계 표시 (개발자 모드 설정)
if (llm.ShowTotalCallStats)
{
var totalTokens = statsInputTokens + statsOutputTokens;
var durationSec = durationMs / 1000.0;
var toolList = string.Join(", ", statsUsedTools);
var retryTotal = statsRepeatedFailureBlocks + statsRecoveredAfterFailure;
var retryQuality = retryTotal > 0
? $"{(statsRecoveredAfterFailure * 100.0 / retryTotal):F0}%"
: "100%";
var compactNoiseSummary = _runPostCompactionSuppressedThinkingCount > 0
? $" | compact 로그 축약 {_runPostCompactionSuppressedThinkingCount}건"
: "";
var compactToolResultSummary = _runPostCompactionToolResultCompactions > 0
? $" | compact 결과 축약 {_runPostCompactionToolResultCompactions}건"
: "";
var topFailed = BuildTopFailureSummary(failedToolHistogram);
var summary = $"📊 전체 통계: LLM {iteration}회 호출 | 도구 {totalToolCalls}회 (성공 {statsSuccessCount}, 실패 {statsFailCount}) | " +
$"토큰 {statsInputTokens:N0}→{statsOutputTokens:N0} (합계 {totalTokens:N0}) | " +
$"소요 {durationSec:F1}초 | 재시도 품질 {retryQuality} (복구 {statsRecoveredAfterFailure}, 차단 {statsRepeatedFailureBlocks}){compactNoiseSummary}{compactToolResultSummary} | " +
$"실패 상위: {topFailed} | 사용 도구: {toolList}";
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 ?? "",
});
var completedRunId = _currentRunId;
FinalizeRun(new AgentLoopRunFinalizerState(
completedRunId,
runStopwatch,
statsStart,
explorationState,
taskPolicy,
iteration,
totalToolCalls,
statsSuccessCount,
statsFailCount,
statsInputTokens,
statsOutputTokens,
statsRepeatedFailureBlocks,
statsRecoveredAfterFailure,
statsUsedTools,
failedToolHistogram));
}
}