Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,250 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
///
/// 2단계 압축 전략:
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
/// 2단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
/// </summary>
public static class ContextCondenser
{
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
private const int MaxToolResultChars = 1500;
/// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6;
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</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
_ => 16_000, // Ollama/vLLM 로컬 모델 기본값
};
}
/// <summary>
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
/// 2단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
/// </summary>
public static async Task<bool> CondenseIfNeededAsync(
List<ChatMessage> messages, LlmService llm, int maxOutputTokens, CancellationToken ct = default)
{
if (messages.Count < 6) return false;
// 현재 모델의 입력 토큰 한도
var settings = llm.GetCurrentModelInfo();
var inputLimit = GetModelInputLimit(settings.service, settings.model);
var threshold = (int)(inputLimit * 0.65); // 65%에서 압축 시작
var currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold) return false;
bool didCompress = false;
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
didCompress |= TruncateToolResults(messages);
// 1단계 후 다시 추정
currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold) return didCompress;
// ── 2단계: 이전 대화 LLM 요약 ──
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
if (didCompress)
{
var afterTokens = TokenEstimator.EstimateMessages(messages);
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
}
return didCompress;
}
/// <summary>
/// 1단계: 대용량 도구 결과를 축약합니다.
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
/// </summary>
private static bool TruncateToolResults(List<ChatMessage> messages)
{
bool truncated = false;
// 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지)
var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
for (int i = 0; i < cutoff; i++)
{
var msg = messages[i];
if (msg.Content == null) continue;
// tool_result 메시지의 대용량 출력 축약
if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars)
{
// JSON 구조를 유지하되 output 부분만 축약
messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content));
truncated = true;
}
// assistant의 도구 호출 블록 내 긴 텍스트도 축약
else 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;
}
}
// 일반 assistant/user 메시지 중 비정상적으로 긴 것 (예: 파일 내용 전체 붙여넣기)
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>tool_result JSON 내의 output 값을 축약합니다.</summary>
private static string TruncateToolResultJson(string json)
{
// 간단한 문자열 처리로 output 부분만 축약 (JSON 파서 없이)
const string marker = "\"output\":\"";
var idx = json.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0) return json[..Math.Min(json.Length, MaxToolResultChars)] + "...[축약됨]}";
var outputStart = idx + marker.Length;
// output 끝 찾기 (이스케이프된 따옴표 고려)
var outputEnd = outputStart;
while (outputEnd < json.Length)
{
if (json[outputEnd] == '\\') { outputEnd += 2; continue; }
if (json[outputEnd] == '"') break;
outputEnd++;
}
var outputLen = outputEnd - outputStart;
if (outputLen <= MaxToolResultChars) return json; // 이미 짧음
// 앞부분 + "...[축약됨]" + 뒷부분
var keepLen = MaxToolResultChars / 2;
var prefix = json[..outputStart];
var outputText = json[outputStart..outputEnd];
var suffix = json[outputEnd..];
return prefix +
outputText[..keepLen] +
"\\n...[축약됨: " + $"{outputLen:N0}" + "자 중 " + $"{MaxToolResultChars:N0}" + "자 유지]\\n" +
outputText[^keepLen..] +
suffix;
}
/// <summary>
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
/// </summary>
private static async Task<bool> SummarizeOldMessagesAsync(
List<ChatMessage> messages, LlmService llm, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var systemCount = systemMsg != null ? 1 : 0;
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
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 = $"[이전 대화 요약 — {oldMessages.Count}개 메시지 압축]\n{summary}",
Timestamp = DateTime.Now,
});
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;
}
}
}