에이전트 루프 수명주기와 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

@@ -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

View File

@@ -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\\`

View File

@@ -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 샘플을 확대하는 단계입니다.

View File

@@ -55,4 +55,32 @@ public class AgentMessageInvariantHelperTests
map["call-explicit"].Should().Be(explicitPreview);
}
[Fact]
public void PopulateMissingToolResultPreviews_ShouldRebindPreview_WhenFingerprintMatchesDifferentToolUseId()
{
var messages = new List<ChatMessage>
{
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");
}
}

View File

@@ -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<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)
{
}
}
}
}

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));
}
}

View File

@@ -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<string, string> BuildToolResultPreviewMap(IEnumerable<ChatMessage>? 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<string, string> BuildToolResultPreviewFingerprintMap(IEnumerable<ChatMessage> messages)
{
var previews = new Dictionary<string, string>(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<string, string> BuildPersistedToolResultPreviewMap(IEnumerable<ChatMessage> messages)
{
var previews = new Dictionary<string, string>(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))

View File

@@ -3,8 +3,18 @@ namespace AxCopilot.Services.Agent;
public static class ArtifactQualityOutputFormatter
{
public static IReadOnlyList<string> BuildLines(ArtifactQualityReport review)
=> [review.ToToolSummary(), ArtifactRepairGuideService.BuildGuide(review)];
=> BuildLines(review.ToToolSummary(), ArtifactRepairGuideService.BuildGuide(review));
public static IReadOnlyList<string> BuildLines(DeckQualityReport review)
=> [review.ToToolSummary(), DeckRepairGuideService.BuildGuide(review)];
=> BuildLines(review.ToToolSummary(), DeckRepairGuideService.BuildGuide(review));
private static IReadOnlyList<string> BuildLines(string summary, string repairGuide)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(summary))
lines.Add(summary);
if (!string.IsNullOrWhiteSpace(repairGuide))
lines.Add(repairGuide);
return lines;
}
}

View File

@@ -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);

View File

@@ -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<string>
{
$"Excel 파일 생성 완료: {fullPath}",
$"시트: {totalSheets}개, 총 데이터 행: {totalRows}"
};
outputLines.AddRange(ArtifactQualityOutputFormatter.BuildLines(review));
return ToolResult.Ok(string.Join("\n", outputLines), fullPath);
}
// ═══════════════════════════════════════════════════

View File

@@ -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)