- AxAgentExecutionEngine에서 시스템 프롬프트 중복을 제거하고 structured tool_use/tool_result 전사본을 conversation.Messages로 동기화해 다음 턴과 저장 이력에서도 코드 작업 컨텍스트가 유지되도록 수정 - AgentQueryContextBuilder와 ContextCondenser에 post-compact tool snippet 복원, recent window 확대, tool result 보존 강화 로직을 추가해 장기 코드 실행 중 빌드/파일 근거 손실을 줄임 - MaxContextTokens=0 Auto 모드를 AppSettings, SettingsService 마이그레이션, 설정 UI, 오버레이 UI, 컨텍스트 사용량 표시, LLM 요청 본문에 연결하고 Auto 모드에서는 provider output cap 강제 주입을 제거 - 관련 회귀 테스트와 문서 README/DEVELOPMENT/CODE_CONTEXT_RELIABILITY_PLAN을 갱신하고 깨진 진단 문자열 기대값을 영어 기준으로 정리 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_followup\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AxAgentExecutionEngineTests|AgentQueryContextBuilderTests|ContextCondenserTests|SettingsServiceTests|AgentLoopDiagnosticsFormatterTests" -p:OutputPath=bin\\verify_context_reliability_followup_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_context_reliability_followup_tests2\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_followup_tests2\\ - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_final\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_final\\
863 lines
35 KiB
C#
863 lines
35 KiB
C#
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<string> AppliedStages { get; } = new();
|
|
public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens);
|
|
public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
|
|
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
|
|
///
|
|
/// 5단계 압축 전략:
|
|
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
|
|
/// 2단계 — session memory compact: 이전 경계/요약을 하나의 세션 메모로 통합
|
|
/// 3단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
|
|
/// 4단계 — collapse/snip: 긴 로그/도구 결과를 더 짧은 경계/앞뒤 일부로 절단
|
|
/// 5단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
|
/// </summary>
|
|
public static class ContextCondenser
|
|
{
|
|
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
|
|
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;
|
|
|
|
/// <summary>요약 시 유지할 최근 메시지 수</summary>
|
|
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<ChatMessage> OldMessages { get; init; }
|
|
public required List<ChatMessage> RecentMessages { get; init; }
|
|
public int PreservedToolPairs { get; init; }
|
|
}
|
|
|
|
private sealed class CompactionAttachmentSummary
|
|
{
|
|
public required List<string> AttachedFiles { get; init; }
|
|
public int ImageCount { get; init; }
|
|
}
|
|
|
|
/// <summary>모델별 입력 토큰 한도 (대략).</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
|
|
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
|
|
/// 2단계: microcompact 경량 압축 (토큰이 여전히 높으면)
|
|
/// 3단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
|
|
/// </summary>
|
|
public static async Task<bool> CondenseIfNeededAsync(
|
|
List<ChatMessage> 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<ContextCompactionResult> CondenseWithStatsAsync(
|
|
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고,
|
|
/// 그 외 긴 assistant/user 메시지는 경량 절단합니다.
|
|
/// </summary>
|
|
private static bool TruncateToolResults(List<ChatMessage> 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(),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
|
|
/// LLM 호출 전에 토큰을 한 번 더 줄이는 마이크로 압축 단계입니다.
|
|
/// </summary>
|
|
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> 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<ChatMessage>(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<ChatMessage>();
|
|
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<ChatMessage> 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<string>())
|
|
.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<ChatMessage>(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<ChatMessage> 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<ChatMessage>(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<ChatMessage> { 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<ChatMessage> 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<string>
|
|
{
|
|
$"[이전 실행 묶음 압축 — {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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
|
|
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
|
|
/// </summary>
|
|
private static async Task<bool> SummarizeOldMessagesAsync(
|
|
List<ChatMessage> 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<ChatMessage>
|
|
{
|
|
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<ChatMessage> messages, int maxFiles)
|
|
{
|
|
var attachedFiles = messages
|
|
.SelectMany(m => m.AttachedFiles ?? Enumerable.Empty<string>())
|
|
.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<string>
|
|
{
|
|
$"[이전 대화 요약 — {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)));
|
|
}
|
|
}
|