using AxCopilot.Models; namespace AxCopilot.Services.Agent; public sealed class ContextCompactionResult { public bool Changed { get; set; } public int BeforeTokens { get; set; } public int AfterTokens { get; set; } public bool SessionMemoryApplied { get; set; } public int SessionMemoryMergedMessages { get; set; } public int MicrocompactBoundaryCount { get; set; } public int MicrocompactSingleCount { get; set; } public int SnippedMessageCount { get; set; } public int CollapsedBoundaryCount { get; set; } public List AppliedStages { get; } = new(); public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens); public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages); } /// /// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다. /// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현. /// /// 5단계 압축 전략: /// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음) /// 2단계 — session memory compact: 이전 경계/요약을 하나의 세션 메모로 통합 /// 3단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음) /// 4단계 — collapse/snip: 긴 로그/도구 결과를 더 짧은 경계/앞뒤 일부로 절단 /// 5단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출) /// public static class ContextCondenser { /// 도구 결과 1개당 최대 유지 길이 (자) private const int MaxToolResultChars = 1500; private const int MicrocompactSingleKeepChars = 480; private const int MicrocompactGroupMinCount = 4; private const int SnipKeepHeadChars = 220; private const int SnipKeepTailChars = 140; private const int ToolResultTruncationKeepCount = 6; /// 요약 시 유지할 최근 메시지 수 private const int RecentKeepCount = 12; private const int AutoCompactBufferTokens = 13_000; private const int SummaryReserveTokens = 20_000; private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다."; private readonly record struct TimeBasedToolResultCompactionConfig(bool Enabled, int GapThresholdMinutes, int KeepRecentToolResults); private sealed class CompactionWindow { public ChatMessage? SystemMessage { get; init; } public required List OldMessages { get; init; } public required List RecentMessages { get; init; } public int PreservedToolPairs { get; init; } } private sealed class CompactionAttachmentSummary { public required List AttachedFiles { get; init; } public int ImageCount { get; init; } } /// 모델별 입력 토큰 한도 (대략). private static int GetModelInputLimit(string service, string model) { var key = $"{service}:{model}".ToLowerInvariant(); return key switch { _ when key.Contains(string.Concat("cl", "aude")) => 180_000, // Claude 계열 200K _ when key.Contains("gemini-2.5") => 900_000, // Gemini 1M _ when key.Contains("gemini-2.0") => 900_000, _ when key.Contains("gemini") => 900_000, _ when key.Contains("gpt-4") => 120_000, // GPT-4 128K _ when key.Contains("deepseek") => 128_000, // DeepSeek-V3/R1 128K _ when key.Contains("qwen") => 32_000, // Qwen 계열 32K _ when key.Contains("llama") => 32_000, // LLaMA 계열 32K _ => 32_000, // vLLM/Ollama 알 수 없는 모델 기본값 (보수적으로 32K) }; } public static int ResolveContextBudgetTokens(string service, string model, int configuredLimit) { if (configuredLimit > 0) return Math.Clamp(configuredLimit, 1_024, 1_000_000); return GetModelInputLimit(service, model); } public static int GetEffectiveContextWindowSize(string service, string model, int configuredLimit) { var contextWindow = ResolveContextBudgetTokens(service, model, configuredLimit); var reservedForSummary = Math.Min(SummaryReserveTokens, Math.Max(4_000, contextWindow / 8)); return Math.Max(8_000, contextWindow - reservedForSummary); } /// /// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다. /// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음) /// 2단계: microcompact 경량 압축 (토큰이 여전히 높으면) /// 3단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면) /// public static async Task CondenseIfNeededAsync( List messages, ILlmService llm, int maxOutputTokens, bool proactiveEnabled = true, int triggerPercent = 80, bool force = false, CancellationToken ct = default) { var result = await CondenseWithStatsAsync(messages, llm, maxOutputTokens, proactiveEnabled, triggerPercent, force, ct); return result.Changed; } public static async Task CondenseWithStatsAsync( List messages, ILlmService llm, int maxOutputTokens, bool proactiveEnabled = true, int triggerPercent = 80, bool force = false, CancellationToken ct = default) { var result = new ContextCompactionResult(); if (messages.Count < 6) return result; if (!force && !proactiveEnabled) return result; AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages); // 현재 모델의 입력 토큰 한도 var settings = llm.GetCurrentModelInfo(); // 사용자가 설정한 컨텍스트 크기를 우선 사용. 미설정 시 모델별 기본값 적용. var effectiveWindow = GetEffectiveContextWindowSize(settings.service, settings.model, maxOutputTokens); var percent = Math.Clamp(triggerPercent, 50, 95); var percentThreshold = (int)(effectiveWindow * (percent / 100.0)); var bufferedThreshold = Math.Max(4_000, effectiveWindow - AutoCompactBufferTokens); var threshold = Math.Min(percentThreshold, bufferedThreshold); var currentTokens = TokenEstimator.EstimateMessages(messages); result.BeforeTokens = currentTokens; var timeBasedConfig = GetTimeBasedToolResultCompactionConfig(settings.service, settings.model); if (ApplyTimeBasedToolResultCompaction(messages, timeBasedConfig, out var clearedToolResults)) { result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})"); currentTokens = TokenEstimator.EstimateMessages(messages); } if (!force && currentTokens < threshold) { result.Changed = result.AppliedStages.Count > 0; result.AfterTokens = currentTokens; return result; } var originalTokens = currentTokens; bool didCompress = false; // ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ── if (TruncateToolResults(messages)) { didCompress = true; result.AppliedStages.Add("tool-result"); } // 1단계 후 다시 추정 currentTokens = TokenEstimator.EstimateMessages(messages); if (!force && currentTokens < threshold) { result.Changed = didCompress; result.AfterTokens = currentTokens; return result; } // ── 2단계: 이전 경계/요약을 세션 메모로 통합 ── var sessionMemoryMergedMessages = SessionMemoryCompactOlderMessages(messages); if (sessionMemoryMergedMessages > 0) { didCompress = true; result.SessionMemoryApplied = true; result.SessionMemoryMergedMessages = sessionMemoryMergedMessages; result.AppliedStages.Add("session-memory"); } currentTokens = TokenEstimator.EstimateMessages(messages); if (!force && currentTokens < threshold) { result.Changed = didCompress; result.AfterTokens = currentTokens; return result; } // ── 3단계: 오래된 실행/도구 묶음 경량 압축 ── var (boundaryCount, singleCount) = MicrocompactOlderMessages(messages); if (boundaryCount > 0 || singleCount > 0) { didCompress = true; result.MicrocompactBoundaryCount = boundaryCount; result.MicrocompactSingleCount = singleCount; result.AppliedStages.Add("microcompact"); } currentTokens = TokenEstimator.EstimateMessages(messages); if (!force && currentTokens < threshold) { result.Changed = didCompress; result.AfterTokens = currentTokens; return result; } // ── 4단계: collapse/snip ── var (snippedCount, collapsedCount) = CollapseAndSnipOlderMessages(messages); if (snippedCount > 0 || collapsedCount > 0) { didCompress = true; result.SnippedMessageCount = snippedCount; result.CollapsedBoundaryCount = collapsedCount; result.AppliedStages.Add("snip"); } currentTokens = TokenEstimator.EstimateMessages(messages); if (!force && currentTokens < threshold) { result.Changed = didCompress; result.AfterTokens = currentTokens; return result; } // ── 5단계: 이전 대화 LLM 요약 ── if (await SummarizeOldMessagesAsync(messages, llm, ct)) { didCompress = true; result.AppliedStages.Add("summary"); } if (didCompress) { var afterTokens = TokenEstimator.EstimateMessages(messages); LogService.Info($"Context Condenser: {originalTokens} → {afterTokens} 토큰 (절감 {originalTokens - afterTokens})"); result.Changed = true; result.AfterTokens = afterTokens; } else { result.AfterTokens = currentTokens; } return result; } private static CompactionWindow SplitCompactionWindow(List messages, int extraTrailingProtection) { var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var nonSystemMessages = messages.Where(m => m.Role != "system").ToList(); var trailingCount = Math.Min(RecentKeepCount + Math.Max(0, extraTrailingProtection), nonSystemMessages.Count); var splitStart = Math.Max(0, nonSystemMessages.Count - trailingCount); var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs( nonSystemMessages, splitStart, out var preservedToolPairs); return new CompactionWindow { SystemMessage = systemMsg, OldMessages = nonSystemMessages.Take(adjustedStart).ToList(), RecentMessages = nonSystemMessages.Skip(adjustedStart).ToList(), PreservedToolPairs = preservedToolPairs, }; } private static TimeBasedToolResultCompactionConfig GetTimeBasedToolResultCompactionConfig(string service, string model) { var key = $"{service}:{model}".ToLowerInvariant(); return key switch { _ when key.Contains(string.Concat("cl", "aude")) => new TimeBasedToolResultCompactionConfig(true, 60, 5), _ when key.Contains("gemini") => new TimeBasedToolResultCompactionConfig(true, 45, 3), _ when key.Contains("gpt-4") => new TimeBasedToolResultCompactionConfig(true, 45, 3), _ when key.Contains("deepseek") => new TimeBasedToolResultCompactionConfig(true, 30, 2), _ when key.Contains("qwen") => new TimeBasedToolResultCompactionConfig(true, 20, 1), _ when key.Contains("llama") => new TimeBasedToolResultCompactionConfig(true, 20, 1), _ when key.Contains("vllm") => new TimeBasedToolResultCompactionConfig(true, 20, 1), _ => new TimeBasedToolResultCompactionConfig(true, 30, 2), }; } private static bool ApplyTimeBasedToolResultCompaction( List messages, TimeBasedToolResultCompactionConfig config, out int clearedCount) { clearedCount = 0; if (!config.Enabled) return false; var lastAssistant = messages .Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(m => m.Timestamp) .FirstOrDefault(); if (lastAssistant?.Timestamp is not DateTime lastAssistantAt) return false; var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes; if (!double.IsFinite(gapMinutes) || gapMinutes < config.GapThresholdMinutes) return false; var candidateIndexes = messages .Select((message, index) => new { message, index }) .Where(x => AgentMessageInvariantHelper.TryGetToolResultId(x.message, out _)) .Select(x => x.index) .ToList(); if (candidateIndexes.Count <= config.KeepRecentToolResults) return false; var keepSet = candidateIndexes .TakeLast(Math.Max(1, config.KeepRecentToolResults)) .ToHashSet(); foreach (var index in candidateIndexes) { if (keepSet.Contains(index)) continue; var message = messages[index]; var content = message.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) continue; var cleared = AgentToolResultBudget.CreateClearedToolResultJson(content, TimeBasedClearedToolResultMessage); if (string.Equals(cleared, content, StringComparison.Ordinal)) continue; messages[index] = CloneWithContent(message, cleared); clearedCount++; } return clearedCount > 0; } /// /// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고, /// 그 외 긴 assistant/user 메시지는 경량 절단합니다. /// private static bool TruncateToolResults(List messages) { var budgetResult = AgentToolResultBudget.Apply(messages, ToolResultTruncationKeepCount); bool truncated = budgetResult.TruncatedCount > 0; var cutoff = Math.Max(0, messages.Count - ToolResultTruncationKeepCount); for (int i = 0; i < cutoff; i++) { var msg = messages[i]; if (msg.Content == null) continue; if (AgentMessageInvariantHelper.TryGetToolResultId(msg, out _)) { continue; } if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 && msg.Content.StartsWith("{\"_tool_use_blocks\"")) { if (msg.Content.Length > MaxToolResultChars * 3) { messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}"); truncated = true; } } else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system") { messages[i] = CloneWithContent( msg, msg.Content[..MaxToolResultChars] + "\n\n...[이전 내용 축약됨 — 원본 " + $"{msg.Content.Length:N0}자 중 {MaxToolResultChars:N0}자 유지]"); truncated = true; } } return truncated; } private static ChatMessage CloneWithContent(ChatMessage source, string content) { return new ChatMessage { Role = source.Role, Content = content, Timestamp = source.Timestamp, MetaKind = source.MetaKind, MetaRunId = source.MetaRunId, Feedback = source.Feedback, AttachedFiles = source.AttachedFiles?.ToList(), Images = source.Images?.Select(x => new ImageAttachment { Base64 = x.Base64, MimeType = x.MimeType, FileName = x.FileName, }).ToList(), }; } /// /// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다. /// LLM 호출 전에 토큰을 한 번 더 줄이는 마이크로 압축 단계입니다. /// private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List messages) { var window = SplitCompactionWindow(messages, extraTrailingProtection: 2); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0); var oldMessages = window.OldMessages; var recentMessages = window.RecentMessages; var rewritten = new List(oldMessages.Count); bool changed = false; int boundaryCount = 0; int singleCount = 0; for (int i = 0; i < oldMessages.Count;) { if (IsMicrocompactCandidate(oldMessages[i])) { var group = new List(); int j = i; while (j < oldMessages.Count && IsMicrocompactCandidate(oldMessages[j])) { group.Add(oldMessages[j]); j++; } var totalChars = group.Sum(m => m.Content?.Length ?? 0); if (group.Count >= MicrocompactGroupMinCount || totalChars > MaxToolResultChars * 2) { rewritten.Add(BuildMicrocompactBoundary(group)); changed = true; boundaryCount++; } else { var singleChanged = TryMicrocompactSingle(group[0], out var compacted); rewritten.Add(singleChanged ? compacted : group[0]); changed |= singleChanged; if (singleChanged) singleCount++; } i = j; continue; } if (TryMicrocompactSingle(oldMessages[i], out var singleCompacted)) { rewritten.Add(singleCompacted); changed = true; singleCount++; } else { rewritten.Add(oldMessages[i]); } i++; } if (!changed) return (0, 0); messages.Clear(); if (window.SystemMessage != null) messages.Add(window.SystemMessage); messages.AddRange(rewritten); messages.AddRange(recentMessages); return (boundaryCount, singleCount); } private static int SessionMemoryCompactOlderMessages(List messages) { var window = SplitCompactionWindow(messages, extraTrailingProtection: 3); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return 0; var oldMessages = window.OldMessages; var recentMessages = window.RecentMessages; var candidates = oldMessages .Select((message, index) => new { message, index }) .Where(x => IsSessionMemoryCandidate(x.message)) .ToList(); if (candidates.Count < 2) return 0; var attachedFiles = candidates .SelectMany(x => x.message.AttachedFiles ?? Enumerable.Empty()) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => { try { return System.IO.Path.GetFileName(path); } catch { return path; } }) .Where(name => !string.IsNullOrWhiteSpace(name)) .Take(5) .ToList(); var merged = new ChatMessage { Role = "assistant", Timestamp = candidates.Last().message.Timestamp, MetaKind = "session_memory_compaction", MetaRunId = candidates.Last().message.MetaRunId, AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null, Content = string.Join("\n", new[] { $"[세션 메모리 압축 - {candidates.Count}개 이전 경계 통합]", "- 이전 요약, 실행 경계, 오래된 메타 로그를 하나의 세션 메모로 합쳤습니다.", attachedFiles.Count > 0 ? $"- 관련 파일: {string.Join(", ", attachedFiles)}" : "- 관련 파일: 없음", }) }; var rewritten = new List(oldMessages.Count - candidates.Count + 1); bool inserted = false; for (int i = 0; i < oldMessages.Count; i++) { if (candidates.Any(x => x.index == i)) { if (!inserted) { rewritten.Add(merged); inserted = true; } continue; } rewritten.Add(oldMessages[i]); } messages.Clear(); if (window.SystemMessage != null) messages.Add(window.SystemMessage); messages.AddRange(rewritten); messages.AddRange(recentMessages); return candidates.Count; } private static bool IsSessionMemoryCandidate(ChatMessage message) { var content = message.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) return false; return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) || string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) || content.StartsWith("[이전 대화 요약", StringComparison.Ordinal) || content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal) || content.StartsWith("[이전 도구 결과 축약", StringComparison.Ordinal) || content.StartsWith("[이전 도구 호출 묶음 축약", StringComparison.Ordinal) || content.StartsWith("[이전 실행 메타 축약", StringComparison.Ordinal); } private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List messages) { var window = SplitCompactionWindow(messages, extraTrailingProtection: 2); if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0); var oldMessages = window.OldMessages; var recentMessages = window.RecentMessages; var rewritten = new List(oldMessages.Count); int snippedCount = 0; int collapsedCount = 0; for (int i = 0; i < oldMessages.Count;) { var current = oldMessages[i]; if (IsBoundaryLike(current)) { var group = new List { current }; int j = i + 1; while (j < oldMessages.Count && IsBoundaryLike(oldMessages[j])) { group.Add(oldMessages[j]); j++; } if (group.Count >= 2) { rewritten.Add(new ChatMessage { Role = "assistant", Timestamp = group.Last().Timestamp, MetaKind = "collapsed_boundary", MetaRunId = group.Last().MetaRunId, Content = $"[이전 압축 경계 병합 - {group.Count}개]\n- 이전 compact/snippet 경계를 하나로 합쳤습니다." }); collapsedCount += group.Count; i = j; continue; } } if (TrySnipMessage(current, out var snipped)) { rewritten.Add(snipped); snippedCount++; } else { rewritten.Add(current); } i++; } if (snippedCount == 0 && collapsedCount == 0) return (0, 0); messages.Clear(); if (window.SystemMessage != null) messages.Add(window.SystemMessage); messages.AddRange(rewritten); messages.AddRange(recentMessages); return (snippedCount, collapsedCount); } private static bool IsBoundaryLike(ChatMessage message) { var content = message.Content ?? ""; return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) || string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) || content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal) || content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal) || content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal); } private static bool TrySnipMessage(ChatMessage source, out ChatMessage snipped) { snipped = source; var content = source.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) return false; if (source.Role == "system") return false; if (content.Length < 900 && content.Split('\n').Length < 12) return false; var normalized = content.Replace("\r\n", "\n"); var lines = normalized .Split('\n') .Select(line => line.TrimEnd()) .Where(line => !string.IsNullOrWhiteSpace(line)) .ToList(); if (lines.Count >= 6) { var headLines = lines.Take(3); var tailLines = lines.TakeLast(2); snipped = CloneWithContent( source, string.Join("\n", headLines) + "\n...[snip: 중간 실행 로그/출력 축약]...\n" + string.Join("\n", tailLines)); return true; } var head = normalized[..Math.Min(SnipKeepHeadChars, normalized.Length)]; var tail = normalized.Length > SnipKeepTailChars ? normalized[^SnipKeepTailChars..] : ""; snipped = CloneWithContent( source, head + "\n...[snip: 중간 내용 축약]...\n" + tail); return true; } private static bool IsMicrocompactCandidate(ChatMessage message) { var content = message.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) return false; // P3: 세션 학습 메시지는 압축 대상에서 제외 — 매 반복 갱신되므로 항상 보존 if (string.Equals(message.MetaKind, "session_learnings", StringComparison.OrdinalIgnoreCase)) return false; return message.MetaKind != null || content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal) || content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) || (message.Role != "system" && content.Length > MaxToolResultChars); } private static bool TryMicrocompactSingle(ChatMessage source, out ChatMessage compacted) { compacted = source; var content = source.Content ?? ""; if (string.IsNullOrWhiteSpace(content)) return false; if (source.Role == "system") return false; if (content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)) { compacted = CloneWithContent( source, $"[이전 도구 결과 축약]\n- 상세 출력은 압축되었습니다.\n- 원본 길이: {content.Length:N0}자"); return true; } if (content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)) { compacted = CloneWithContent( source, $"[이전 도구 호출 묶음 축약]\n- 도구 호출 구조는 유지하지 않고 요약만 남겼습니다.\n- 원본 길이: {content.Length:N0}자"); return true; } if (source.MetaKind != null) { compacted = CloneWithContent( source, $"[이전 실행 메타 축약]\n- 종류: {source.MetaKind}\n- 자세한 실행 이벤트는 압축되었습니다."); return true; } if (content.Length <= MaxToolResultChars) return false; var head = content[..Math.Min(MicrocompactSingleKeepChars, content.Length)]; compacted = CloneWithContent( source, head + $"\n\n...[이전 메시지 microcompact 적용 — 원본 {content.Length:N0}자]"); return true; } private static ChatMessage BuildMicrocompactBoundary(List group) { int toolResults = group.Count(m => (m.Content ?? "").StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)); int toolCalls = group.Count(m => (m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)); int metaEvents = group.Count(m => m.MetaKind != null); int longTexts = group.Count(m => !(m.Content ?? "").StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal) && !(m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) && m.MetaKind == null); var attachmentSummary = CollectCompactionAttachmentSummary(group, 4); var lines = new List { $"[이전 실행 묶음 압축 — {group.Count}개 메시지]" }; if (toolResults > 0) lines.Add($"- 도구 결과 {toolResults}건 정리"); if (toolCalls > 0) lines.Add($"- 도구 호출 {toolCalls}건 정리"); if (metaEvents > 0) lines.Add($"- 실행 메타/로그 {metaEvents}건 정리"); if (longTexts > 0) lines.Add($"- 긴 설명/출력 {longTexts}건 축약"); if (attachmentSummary.AttachedFiles.Count > 0) lines.Add($"- 관련 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}"); if (attachmentSummary.ImageCount > 0) lines.Add($"- 관련 이미지: {attachmentSummary.ImageCount}개"); return new ChatMessage { Role = "assistant", Content = string.Join("\n", lines), Timestamp = group.Last().Timestamp, MetaKind = "microcompact_boundary", MetaRunId = group.Last().MetaRunId, AttachedFiles = attachmentSummary.AttachedFiles.Count > 0 ? attachmentSummary.AttachedFiles : null, }; } /// /// 2단계: 오래된 메시지를 LLM으로 요약합니다. /// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다. /// private static async Task SummarizeOldMessagesAsync( List messages, ILlmService llm, CancellationToken ct) { var window = SplitCompactionWindow(messages, extraTrailingProtection: 0); var systemMsg = window.SystemMessage; var oldMessages = window.OldMessages; var recentMessages = window.RecentMessages; var attachmentSummary = CollectCompactionAttachmentSummary(oldMessages, 6); if (oldMessages.Count < 3) return false; // 요약 대상 텍스트 구성 var sb = new System.Text.StringBuilder(); sb.AppendLine("다음 대화 기록을 간결하게 요약하세요."); sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과."); sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정."); sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---"); foreach (var m in oldMessages) { var content = m.Content ?? ""; if (content.StartsWith("{\"_tool_use_blocks\"")) content = "[도구 호출]"; else if (content.StartsWith("{\"type\":\"tool_result\"")) content = "[도구 결과]"; else if (content.Length > 300) content = content[..300] + "..."; sb.AppendLine($"[{m.Role}]: {content}"); } try { var summaryMessages = new List { new() { Role = "user", Content = sb.ToString() } }; var summary = await llm.SendAsync(summaryMessages, ct); if (string.IsNullOrEmpty(summary)) return false; // 메시지 재구성: system + 요약 + 최근 메시지 messages.Clear(); if (systemMsg != null) messages.Add(systemMsg); messages.Add(new ChatMessage { Role = "user", Content = BuildSummaryMessageContent(oldMessages.Count, summary, attachmentSummary), Timestamp = DateTime.Now, AttachedFiles = attachmentSummary.AttachedFiles.Count > 0 ? attachmentSummary.AttachedFiles : null, }); messages.Add(new ChatMessage { Role = "assistant", Content = "이전 대화 내용을 확인했습니다. 이어서 작업하겠습니다.", Timestamp = DateTime.Now, }); messages.AddRange(recentMessages); return true; } catch (Exception ex) { LogService.Warn($"Context Condenser 요약 실패: {ex.Message}"); return false; } } private static CompactionAttachmentSummary CollectCompactionAttachmentSummary(IEnumerable messages, int maxFiles) { var attachedFiles = messages .SelectMany(m => m.AttachedFiles ?? Enumerable.Empty()) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => { try { return System.IO.Path.GetFileName(path); } catch { return path; } }) .Where(name => !string.IsNullOrWhiteSpace(name)) .Take(Math.Max(1, maxFiles)) .ToList(); var imageCount = messages.Sum(m => m.Images?.Count ?? 0); return new CompactionAttachmentSummary { AttachedFiles = attachedFiles, ImageCount = imageCount, }; } private static string BuildSummaryMessageContent(int oldMessageCount, string summary, CompactionAttachmentSummary attachmentSummary) { var lines = new List { $"[이전 대화 요약 — {oldMessageCount}개 메시지 압축]", summary.Trim() }; if (attachmentSummary.AttachedFiles.Count > 0) lines.Add($"참고 파일: {string.Join(", ", attachmentSummary.AttachedFiles)}"); if (attachmentSummary.ImageCount > 0) lines.Add($"참고 이미지: {attachmentSummary.ImageCount}개"); return string.Join("\n", lines.Where(x => !string.IsNullOrWhiteSpace(x))); } }