diff --git a/README.md b/README.md index 281e306..5c22525 100644 --- a/README.md +++ b/README.md @@ -1948,3 +1948,12 @@ MIT License - 테스트는 [CodeLanguageCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs)와 [WorkspaceContextGeneratorTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs)를 확장해 workflow summary와 workspace context 내 language workflow 섹션을 회귀 검증합니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_language_workflow\\ -p:IntermediateOutputPath=obj\\verify_language_workflow\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|WorkspaceContextGeneratorTests" -p:OutputPath=bin\\verify_language_workflow_tests\\ -p:IntermediateOutputPath=obj\\verify_language_workflow_tests\\` 통과 33 + +업데이트: 2026-04-15 10:05 (KST) +- 에이전틱 루프의 시작/종료 책임을 helper로 더 분리했습니다. 새 [AgentLoopRunLifecycle.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs)는 run bootstrap, runtime finalization, transient state reset을 담당하고, [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 실제 반복 orchestration에 더 집중하도록 정리했습니다. +- 이 과정에서 장기 세션 로그 정확도도 함께 보강했습니다. run 종료 후 `_currentRunId`를 먼저 비워 성능 로그에 빈 run id가 남던 흐름을 고쳐, 종료 metric에도 실제 run id가 안정적으로 기록됩니다. +- `tool_result` preview 복원은 `tool_use_id`뿐 아니라 fingerprint 재바인딩까지 지원합니다. [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)는 같은 tool output이 다른 세션/분기에서 재등장할 때 기존 preview를 현재 `tool_use_id`에 맞춰 다시 묶어 저장/재개 후 query preview 일관성을 더 높입니다. +- 문서 포맷 품질 출력도 DOCX/PPTX/Excel 멀티시트 경로까지 공통화했습니다. [ArtifactQualityOutputFormatter.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityOutputFormatter.cs)를 [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs), [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs), [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에도 연결해 quality summary와 repair guide 반환 형식을 한 줄기로 맞췄습니다. +- 테스트는 [AgentMessageInvariantHelperTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs)에 fingerprint 재바인딩 회귀를 추가했고, golden 회귀는 [PptxSkillGoldenDeckTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs), [ExcelSkillGoldenWorkbookTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillGoldenWorkbookTests.cs), [DocxSkillGoldenDocumentTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocxSkillGoldenDocumentTests.cs), [HtmlSkillGoldenReportTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillGoldenReportTests.cs) 기준으로 다시 확인했습니다. +- 검증: `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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f6db739..41425a4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1022,3 +1022,26 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_language_workflow\\ -p:IntermediateOutputPath=obj\\verify_language_workflow\\` - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|WorkspaceContextGeneratorTests" -p:OutputPath=bin\\verify_language_workflow_tests\\ -p:IntermediateOutputPath=obj\\verify_language_workflow_tests\\` + +업데이트: 2026-04-15 10:05 (KST) +- `AgentLoopRunLifecycle.cs` 추가: + - `BeginRun()`으로 run stopwatch, user query, iteration budget, retry budget 초기화 + - `BootstrapRunAsync()`로 intent 분류, exploration/path state, session learnings, task/execution policy 계산 분리 + - `FinalizeRun()`으로 run summary metric, exploration breadth, stats 기록, transient state reset 일원화 +- `AgentLoopService.RunAsync()` 정리: + - 시작부의 `run id/iteration budget/intent bootstrap` 블록을 helper 호출로 대체 + - 종료부의 run summary logging을 helper 호출로 대체 + - 종료 metric에 빈 run id가 남던 흐름을 수정해 실제 run id를 유지 +- `AgentMessageInvariantHelper` 고도화: + - persisted preview map과 fingerprint preview map을 분리 + - 같은 tool output이 다른 `tool_use_id`로 재등장할 때 preview를 현재 id로 재바인딩 + - preview 탐색 우선순위를 `저장된 preview → fingerprint 재바인딩 → synthetic` 순서로 고정 +- 문서 품질 출력 경로 정리: + - `ArtifactQualityOutputFormatter`를 `DocxSkill`, `PptxSkill`, `ExcelSkill` 멀티시트 반환 경로까지 연결 + - 포맷별 quality summary/repair guide 문자열 조립을 공통 helper로 통일 +- 테스트 보강: + - `AgentMessageInvariantHelperTests`: fingerprint 기반 preview 재바인딩 회귀 추가 + - 재검증 대상: `AgentCommandQueueTests`, `AgentQueuedCommandProjectorTests`, `AgentQueryContextBuilderTests`, `ChatStorageServiceTests`, `PptxSkillGoldenDeckTests`, `ExcelSkillGoldenWorkbookTests`, `DocxSkillGoldenDocumentTests`, `HtmlSkillGoldenReportTests` +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_run_finalize\\ -p:IntermediateOutputPath=obj\\verify_run_finalize\\` + - `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\\` diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 0140a68..c187041 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -150,3 +150,10 @@ 1. 개발언어 고도화는 `지원 언어 표시`를 넘어서 `Language Workflow`를 실제 컨텍스트에 주입하는 단계로 넘어갔습니다. 2. 다음 구현 배치는 `AgentLoopService` 세분화, `tool_result replacement state` 장기 세션 고정, 문서 포맷 공통 quality formatter 확장 순으로 진행합니다. 3. 문서 포맷은 PPTX가 가장 앞서 있고, DOCX/XLSX/HTML은 공통 critic/repair와 golden 회귀를 같은 수준으로 끌어올리는 마감 단계에 들어갑니다. + +업데이트: 2026-04-15 10:05 (KST) + +### 추가 진행 메모 +1. 에이전틱 루프는 이제 `queued command projector + run lifecycle helper` 구조까지 들어와 시작/종료 책임이 분리됐습니다. 다음 마감 단계는 `RunAsync` 본체의 iteration pipeline을 더 잘게 나누는 작업입니다. +2. `tool_result replacement state`는 synthetic preview를 넘어 fingerprint 재바인딩까지 들어갔습니다. 남은 방향은 compact/branch 이후의 replacement policy를 세션 단위 상태로 더 오래 유지하는 것입니다. +3. 문서 포맷은 `ArtifactQualityOutputFormatter`가 HTML/XLSX뿐 아니라 DOCX/PPTX까지 확장되었습니다. 다음 마감은 포맷별 critic/repair 자체를 더 깊게 하고, golden fixture 샘플을 확대하는 단계입니다. diff --git a/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs b/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs index ffce187..68b40e8 100644 --- a/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs +++ b/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs @@ -55,4 +55,32 @@ public class AgentMessageInvariantHelperTests map["call-explicit"].Should().Be(explicitPreview); } + + [Fact] + public void PopulateMissingToolResultPreviews_ShouldRebindPreview_WhenFingerprintMatchesDifferentToolUseId() + { + var messages = new List + { + new() + { + MsgId = "tool-result-1", + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-original","tool_name":"file_read","content":"same long output that should map across sessions"}""", + QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-original","tool_name":"file_read","content":"stable preview"}""" + }, + new() + { + MsgId = "tool-result-2", + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-replayed","tool_name":"file_read","content":"same long output that should map across sessions"}""" + } + }; + + var changed = AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages); + + changed.Should().BeTrue(); + messages[1].QueryPreviewContent.Should().Contain("call-replayed"); + messages[1].QueryPreviewContent.Should().Contain("stable preview"); + messages[1].QueryPreviewContent.Should().NotContain("call-original"); + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs b/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs new file mode 100644 index 0000000..e1af779 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopRunLifecycle.cs @@ -0,0 +1,228 @@ +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 StatsUsedTools, + IReadOnlyDictionary FailedToolHistogram); + + private (Stopwatch RunStopwatch, string UserQuery, int MaxIterations, int MaxRetry) BeginRun(List 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 BootstrapRunAsync( + List 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(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) + { + } + } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index fce4bfb..0c7b07e 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -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(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)); } } diff --git a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs index cf56efa..7a8a1be 100644 --- a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs +++ b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs @@ -9,6 +9,7 @@ namespace AxCopilot.Services.Agent; internal static class AgentMessageInvariantHelper { private const int SyntheticPreviewTextLimit = 280; + private const int PreviewFingerprintTextLimit = 480; public static Dictionary BuildToolResultPreviewMap(IEnumerable? messages) { @@ -46,8 +47,9 @@ internal static class AgentMessageInvariantHelper if (messages == null || messages.Count == 0) return false; - var previews = BuildToolResultPreviewMap(messages); - if (previews.Count == 0) + var previews = BuildPersistedToolResultPreviewMap(messages); + var previewsByFingerprint = BuildToolResultPreviewFingerprintMap(messages); + if (previews.Count == 0 && previewsByFingerprint.Count == 0) return false; var changed = false; @@ -57,8 +59,23 @@ internal static class AgentMessageInvariantHelper continue; if (!TryGetToolResultId(message, out var toolResultId)) continue; + if (!previews.TryGetValue(toolResultId, out var preview)) + { + if (!TryGetToolResultFingerprint(message, out var fingerprint) + || !previewsByFingerprint.TryGetValue(fingerprint, out var fingerprintPreview)) + { + continue; + } + + preview = TryRebindToolResultPreview(fingerprintPreview, toolResultId, message, out var reboundPreview) + ? reboundPreview + : fingerprintPreview; + } + else if (string.IsNullOrWhiteSpace(preview) && !TryBuildSyntheticToolResultPreview(message, out preview)) + { continue; + } message.QueryPreviewContent = preview; changed = true; @@ -217,6 +234,142 @@ internal static class AgentMessageInvariantHelper } } + private static Dictionary BuildToolResultPreviewFingerprintMap(IEnumerable messages) + { + var previews = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var message in messages) + { + if (!TryGetToolResultFingerprint(message, out var fingerprint)) + continue; + + if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent)) + { + previews[fingerprint] = message.QueryPreviewContent!; + continue; + } + + if (previews.ContainsKey(fingerprint)) + continue; + + if (TryBuildSyntheticToolResultPreview(message, out var preview)) + previews[fingerprint] = preview; + } + + return previews; + } + + private static Dictionary BuildPersistedToolResultPreviewMap(IEnumerable messages) + { + var previews = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var message in messages) + { + if (string.IsNullOrWhiteSpace(message.QueryPreviewContent)) + continue; + if (!TryGetToolResultId(message, out var toolResultId)) + continue; + + previews[toolResultId] = message.QueryPreviewContent!; + } + + return previews; + } + + private static bool TryGetToolResultFingerprint(ChatMessage message, out string fingerprint) + { + fingerprint = ""; + if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)) + return false; + + var content = message.Content ?? ""; + if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)) + return false; + + try + { + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + var toolName = root.TryGetProperty("tool_name", out var toolNameEl) + ? toolNameEl.GetString() ?? "" + : ""; + var previewText = ExtractPreviewText(root); + if (string.IsNullOrWhiteSpace(previewText)) + return false; + + if (previewText.Length > PreviewFingerprintTextLimit) + previewText = previewText[..PreviewFingerprintTextLimit]; + + fingerprint = $"{toolName.Trim().ToLowerInvariant()}::{previewText.Trim()}"; + return !string.IsNullOrWhiteSpace(fingerprint); + } + catch + { + return false; + } + } + + private static bool TryRebindToolResultPreview(string preview, string toolResultId, ChatMessage message, out string reboundPreview) + { + reboundPreview = preview; + if (string.IsNullOrWhiteSpace(preview) || string.IsNullOrWhiteSpace(toolResultId)) + return false; + + try + { + using var previewDoc = JsonDocument.Parse(preview); + if (previewDoc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + var toolName = TryGetToolResultToolName(message, out var extractedToolName) + ? extractedToolName + : (previewDoc.RootElement.TryGetProperty("tool_name", out var previewToolNameEl) + ? previewToolNameEl.GetString() ?? "" + : ""); + var content = previewDoc.RootElement.TryGetProperty("content", out var contentEl) + ? contentEl.GetString() ?? "" + : ""; + + reboundPreview = JsonSerializer.Serialize(new + { + type = "tool_result", + tool_use_id = toolResultId, + tool_name = toolName, + content + }); + return true; + } + catch + { + return false; + } + } + + private static bool TryGetToolResultToolName(ChatMessage message, out string toolName) + { + toolName = ""; + if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)) + return false; + + var content = message.Content ?? ""; + if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)) + return false; + + try + { + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("tool_name", out var toolNameEl)) + return false; + + toolName = toolNameEl.GetString() ?? ""; + return !string.IsNullOrWhiteSpace(toolName); + } + catch + { + return false; + } + } + private static string ExtractPreviewText(JsonElement root) { if (root.TryGetProperty("content", out var contentEl)) diff --git a/src/AxCopilot/Services/Agent/ArtifactQualityOutputFormatter.cs b/src/AxCopilot/Services/Agent/ArtifactQualityOutputFormatter.cs index 9f983fd..5d0a385 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityOutputFormatter.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityOutputFormatter.cs @@ -3,8 +3,18 @@ namespace AxCopilot.Services.Agent; public static class ArtifactQualityOutputFormatter { public static IReadOnlyList BuildLines(ArtifactQualityReport review) - => [review.ToToolSummary(), ArtifactRepairGuideService.BuildGuide(review)]; + => BuildLines(review.ToToolSummary(), ArtifactRepairGuideService.BuildGuide(review)); public static IReadOnlyList BuildLines(DeckQualityReport review) - => [review.ToToolSummary(), DeckRepairGuideService.BuildGuide(review)]; + => BuildLines(review.ToToolSummary(), DeckRepairGuideService.BuildGuide(review)); + + private static IReadOnlyList BuildLines(string summary, string repairGuide) + { + var lines = new List(); + if (!string.IsNullOrWhiteSpace(summary)) + lines.Add(summary); + if (!string.IsNullOrWhiteSpace(repairGuide)) + lines.Add(repairGuide); + return lines; + } } diff --git a/src/AxCopilot/Services/Agent/DocxSkill.cs b/src/AxCopilot/Services/Agent/DocxSkill.cs index f869139..a47cfaf 100644 --- a/src/AxCopilot/Services/Agent/DocxSkill.cs +++ b/src/AxCopilot/Services/Agent/DocxSkill.cs @@ -338,8 +338,7 @@ public class DocxSkill : IAgentTool headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "appendix", "reference", "supplement", "부록", "참고")) )); - parts.Add(review.ToToolSummary()); - parts.Add(ArtifactRepairGuideService.BuildGuide(review)); + parts.AddRange(ArtifactQualityOutputFormatter.BuildLines(review)); return ToolResult.Ok( $"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}", fullPath); diff --git a/src/AxCopilot/Services/Agent/ExcelSkill.cs b/src/AxCopilot/Services/Agent/ExcelSkill.cs index 40f2c1a..27adca1 100644 --- a/src/AxCopilot/Services/Agent/ExcelSkill.cs +++ b/src/AxCopilot/Services/Agent/ExcelSkill.cs @@ -478,9 +478,13 @@ public class ExcelSkill : IAgentTool HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series") || HasSummaryItems(summarySheet, "dashboard_tiles") || HasSummaryItems(summarySheet, "variance_series"), HasStructuredSummaryContent(summarySheet, "decision_summary"), HasSummaryItems(summarySheet, "sheet_summaries"))); - return ToolResult.Ok( - $"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}\n{review.ToToolSummary()}\n{ArtifactRepairGuideService.BuildGuide(review)}", - fullPath); + var outputLines = new List + { + $"Excel 파일 생성 완료: {fullPath}", + $"시트: {totalSheets}개, 총 데이터 행: {totalRows}" + }; + outputLines.AddRange(ArtifactQualityOutputFormatter.BuildLines(review)); + return ToolResult.Ok(string.Join("\n", outputLines), fullPath); } // ═══════════════════════════════════════════════════ diff --git a/src/AxCopilot/Services/Agent/PptxSkill.cs b/src/AxCopilot/Services/Agent/PptxSkill.cs index bac9c7d..f0eef91 100644 --- a/src/AxCopilot/Services/Agent/PptxSkill.cs +++ b/src/AxCopilot/Services/Agent/PptxSkill.cs @@ -1029,10 +1029,7 @@ public class PptxSkill : IAgentTool if (!string.IsNullOrWhiteSpace(templatePackName)) outputParts.Add($"Template pack: {templatePackName}"); if (deckReview != null) - { - outputParts.Add(deckReview.ToToolSummary()); - outputParts.Add(DeckRepairGuideService.BuildGuide(deckReview)); - } + outputParts.AddRange(ArtifactQualityOutputFormatter.BuildLines(deckReview)); return ToolResult.Ok(string.Join("\n", outputParts), fullPath); } catch (Exception ex)