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

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