압축 경계에서도 tool pair 불변식을 유지하도록 보강

claw-code의 sessionMemoryCompact 흐름을 참고해 AX Agent가 recent window를 자를 때도 tool_use/tool_result 짝이 끊기지 않도록 보정 범위를 넓혔다.

- AgentMessageInvariantHelper를 추가해 tool_result와 assistant _tool_use_blocks 대응 관계를 공용 로직으로 정리

- AgentQueryContextBuilder는 공용 helper를 사용하도록 단순화

- ContextCondenser의 microcompact, session memory compact, snip, 요약 단계가 모두 같은 split helper로 recent window를 계산하도록 조정

- compaction split 시 recent window 안의 tool_result가 old window의 tool_use를 잃는 상황을 줄여 API pairing 안정성을 높임

- README.md, docs/DEVELOPMENT.md에 2026-04-12 21:39 (KST) 기준 이력 반영

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
This commit is contained in:
2026-04-12 21:40:22 +09:00
parent 0f83dc802c
commit 0a2e5b2d49
5 changed files with 190 additions and 147 deletions

View File

@@ -43,6 +43,14 @@ public static class ContextCondenser
private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000;
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; }
}
/// <summary>모델별 입력 토큰 한도 (대략).</summary>
private static int GetModelInputLimit(string service, string model)
{
@@ -212,6 +220,26 @@ public static class ContextCondenser
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,
};
}
/// <summary>
/// 1단계: 대용량 도구 결과를 축약합니다.
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
@@ -286,13 +314,10 @@ public static class ContextCondenser
/// </summary>
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
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;
@@ -347,7 +372,7 @@ public static class ContextCondenser
if (!changed) return (0, 0);
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return (boundaryCount, singleCount);
@@ -355,13 +380,10 @@ public static class ContextCondenser
private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 3) return 0;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
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 })
@@ -415,7 +437,7 @@ public static class ContextCondenser
}
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return candidates.Count;
@@ -437,13 +459,10 @@ public static class ContextCondenser
private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
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;
@@ -494,7 +513,7 @@ public static class ContextCondenser
if (snippedCount == 0 && collapsedCount == 0) return (0, 0);
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return (snippedCount, collapsedCount);
@@ -680,13 +699,10 @@ public static class ContextCondenser
private static async Task<bool> SummarizeOldMessagesAsync(
List<ChatMessage> messages, ILlmService 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();
var window = SplitCompactionWindow(messages, extraTrailingProtection: 0);
var systemMsg = window.SystemMessage;
var oldMessages = window.OldMessages;
var recentMessages = window.RecentMessages;
if (oldMessages.Count < 3) return false;