목적: - 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
229 lines
9.6 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|