Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs
lacvet 8c0aa98408 에이전트 루프 수명주기와 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
2026-04-15 10:07:01 +09:00

229 lines
9.6 KiB
C#

using System.Diagnostics;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private sealed record AgentLoopRunBootstrap(
Stopwatch RunStopwatch,
string UserQuery,
int MaxIterations,
int MaxRetry,
IntentResult IntentResult,
ExplorationTrackingState ExplorationState,
PathAccessTrackingState PathAccessState,
SessionLearningCollector? SessionLearnings,
TaskTypePolicy TaskPolicy,
ModelExecutionProfileCatalog.ExecutionPolicy ExecutionPolicy);
private sealed record AgentLoopRunFinalizerState(
string RunId,
Stopwatch RunStopwatch,
DateTime StatsStart,
ExplorationTrackingState ExplorationState,
TaskTypePolicy TaskPolicy,
int Iteration,
int TotalToolCalls,
int StatsSuccessCount,
int StatsFailCount,
int StatsInputTokens,
int StatsOutputTokens,
int StatsRepeatedFailureBlocks,
int StatsRecoveredAfterFailure,
IReadOnlyList<string> StatsUsedTools,
IReadOnlyDictionary<string, int> FailedToolHistogram);
private (Stopwatch RunStopwatch, string UserQuery, int MaxIterations, int MaxRetry) BeginRun(List<ChatMessage> messages)
{
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 maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : 3;
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
return (runStopwatch, userQuery, baseMax, maxRetry);
}
private async Task<AgentLoopRunBootstrap> BootstrapRunAsync(
List<ChatMessage> messages,
Stopwatch runStopwatch,
string userQuery,
int maxIterations,
int maxRetry,
CancellationToken ct)
{
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();
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings)
: null;
var taskPolicy = TaskTypePolicy.FromTaskType(intentResult.TaskType);
var executionPolicy = ExecutionPolicyMerger.Apply(
_llm.GetActiveExecutionPolicy(),
intentResult.PolicyOverlay);
maxRetry = ComputeAdaptiveMaxRetry(maxRetry, taskPolicy.TaskType);
var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType);
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
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",
});
}
return new AgentLoopRunBootstrap(
runStopwatch,
userQuery,
maxIterations,
maxRetry,
intentResult,
explorationState,
pathAccessState,
sessionLearnings,
taskPolicy,
executionPolicy);
}
private void FinalizeRun(AgentLoopRunFinalizerState state)
{
var compactNoiseSuppressed = _runPostCompactionSuppressedThinkingCount;
var compactedToolResultCount = _runPostCompactionToolResultCompactions;
WorkflowLogService.LogAgentLifecycle(
_conversationId,
state.RunId,
"end",
summary: $"iterations={state.Iteration}, tools={state.TotalToolCalls}, success={state.StatsSuccessCount}, fail={state.StatsFailCount}");
if (state.TotalToolCalls > 0)
{
AgentPerformanceLogService.LogExplorationBreadth(
_conversationId,
ActiveTab,
new
{
scope = state.ExplorationState.Scope.ToString().ToLowerInvariant(),
folder_map_calls = state.ExplorationState.FolderMapCalls,
total_files_read = state.ExplorationState.TotalFilesRead,
multi_read_files = state.ExplorationState.MultiReadFilesRead,
broad_scan = state.ExplorationState.BroadScanDetected,
selective_hit = state.ExplorationState.SelectiveHit,
corrective_hint = state.ExplorationState.CorrectiveHintInjected
});
var durationMs = (long)(DateTime.Now - state.StatsStart).TotalMilliseconds;
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
{
Timestamp = state.StatsStart,
Tab = ActiveTab ?? "",
TaskType = state.TaskPolicy.TaskType,
Model = _settings.Settings.Llm.Model ?? "",
ToolCalls = state.TotalToolCalls,
SuccessCount = state.StatsSuccessCount,
FailCount = state.StatsFailCount,
InputTokens = state.StatsInputTokens,
OutputTokens = state.StatsOutputTokens,
DurationMs = durationMs,
RepeatedFailureBlockedCount = state.StatsRepeatedFailureBlocks,
RecoveredAfterFailureCount = state.StatsRecoveredAfterFailure,
UsedTools = [.. state.StatsUsedTools],
});
var llm = _settings.Settings.Llm;
if (llm.ShowTotalCallStats)
{
var totalTokens = state.StatsInputTokens + state.StatsOutputTokens;
var durationSec = durationMs / 1000.0;
var toolList = string.Join(", ", state.StatsUsedTools);
var retryTotal = state.StatsRepeatedFailureBlocks + state.StatsRecoveredAfterFailure;
var retryQuality = retryTotal > 0
? $"{(state.StatsRecoveredAfterFailure * 100.0 / retryTotal):F0}%"
: "100%";
var compactNoiseSummary = compactNoiseSuppressed > 0
? $" | compact 로그 축약 {compactNoiseSuppressed}건"
: "";
var compactToolResultSummary = compactedToolResultCount > 0
? $" | compact 결과 축약 {compactedToolResultCount}건"
: "";
var topFailed = BuildTopFailureSummary(new Dictionary<string, int>(state.FailedToolHistogram, StringComparer.OrdinalIgnoreCase));
var summary = $"📊 전체 통계: LLM {state.Iteration}회 호출 | 도구 {state.TotalToolCalls}회 (성공 {state.StatsSuccessCount}, 실패 {state.StatsFailCount}) | " +
$"토큰 {state.StatsInputTokens:N0}→{state.StatsOutputTokens:N0} (합계 {totalTokens:N0}) | " +
$"소요 {durationSec:F1}초 | 재시도 품질 {retryQuality} (복구 {state.StatsRecoveredAfterFailure}, 차단 {state.StatsRepeatedFailureBlocks}){compactNoiseSummary}{compactToolResultSummary} | " +
$"실패 상위: {topFailed} | 사용 도구: {toolList}";
EmitEvent(AgentEventType.StepDone, "total_stats", summary);
}
}
state.RunStopwatch.Stop();
AgentPerformanceLogService.LogMetric(
"agent_loop",
"run_summary",
_conversationId,
ActiveTab ?? "",
state.RunStopwatch.ElapsedMilliseconds,
new
{
runId = state.RunId,
iterations = state.Iteration,
toolCalls = state.TotalToolCalls,
toolSuccess = state.StatsSuccessCount,
toolFail = state.StatsFailCount,
inputTokens = state.StatsInputTokens,
outputTokens = state.StatsOutputTokens,
repeatedFailureBlocks = state.StatsRepeatedFailureBlocks,
recoveredAfterFailure = state.StatsRecoveredAfterFailure,
postCompactionNoiseSuppressed = compactNoiseSuppressed,
postCompactionToolResultCompactions = compactedToolResultCount,
taskType = state.TaskPolicy.TaskType,
model = _settings.Settings.Llm.Model ?? "",
});
ResetRunTransientState();
}
private void ResetRunTransientState()
{
IsRunning = false;
_currentRunId = "";
_runPendingPostCompactionTurn = false;
_runPostCompactionTurnCounter = 0;
_runPostCompactionSuppressedThinkingCount = 0;
_runLastCompactionStageSummary = "";
_runLastCompactionSavedTokens = 0;
_runPostCompactionToolResultCompactions = 0;
if (IsPaused)
{
IsPaused = false;
try
{
_pauseSemaphore.Release();
}
catch (SemaphoreFullException)
{
}
}
}
}