압축 경계에서도 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

@@ -1628,3 +1628,8 @@ MIT License
- 같은 helper에서 `tool_result`가 남아 있는 kept range를 검사해, 대응되는 assistant `_tool_use_blocks`가 잘리지 않도록 window start를 뒤로 보정합니다. - 같은 helper에서 `tool_result`가 남아 있는 kept range를 검사해, 대응되는 assistant `_tool_use_blocks`가 잘리지 않도록 window start를 뒤로 보정합니다.
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 각 반복에서 `messagesForQuery`에 해당하는 전송 뷰를 만든 뒤 `SendWithToolsWithRecoveryAsync()`와 텍스트 fallback 호출에 사용하도록 바꿨습니다. - [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 각 반복에서 `messagesForQuery`에 해당하는 전송 뷰를 만든 뒤 `SendWithToolsWithRecoveryAsync()`와 텍스트 fallback 호출에 사용하도록 바꿨습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `triggerPercent`만 보던 기준에서 `effective context window - output reserve - buffer` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다. - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `triggerPercent`만 보던 기준에서 `effective context window - output reserve - buffer` 개념을 반영해 자동 압축 시작 지점을 더 보수적으로 계산하도록 바꿨습니다.
- 업데이트: 2026-04-12 21:39 (KST)
- `claw-code``sessionMemoryCompact`처럼 압축 경계 자체에서도 `tool_use / tool_result` 짝이 끊기지 않도록 보정 범위를 넓혔습니다.
- [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)를 추가해 `tool_result`가 남는 kept range에서 필요한 assistant `_tool_use_blocks`를 공용 로직으로 다시 포함시키게 했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `MicrocompactOlderMessages`, `SessionMemoryCompactOlderMessages`, `CollapseAndSnipOlderMessages`, `SummarizeOldMessagesAsync`가 모두 같은 split helper를 사용하도록 바꿨습니다.
- 이제 recent window가 `tool_result`로 시작하는 경우에도, 그 결과를 참조하는 이전 `tool_use`가 요약/압축 쪽으로 잘려 나가 API pairing 오류를 만드는 상황을 줄일 수 있습니다.

View File

@@ -585,3 +585,17 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- 부가 정리 - 부가 정리
- `src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs`의 미사용 필드를 제거해 빌드 경고를 없앴습니다. - `src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs`의 미사용 필드를 제거해 빌드 경고를 없앴습니다.
## 압축 경계에서 tool pair 불변식 보정 확대 (2026-04-12 21:39 KST)
- 전송 직전 query view에서만 pair를 보정하던 상태에서 한 단계 더 나아가, compact split 자체도 `tool_use / tool_result` 불변식을 보존하도록 정리했습니다.
- `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs`
- `tool_result`에서 `tool_use_id`를 읽고, assistant `_tool_use_blocks`에서 대응 `tool_use`를 찾는 공용 helper를 추가했습니다.
- query view builder와 context condenser가 같은 보정 규칙을 공유하도록 맞췄습니다.
- `src/AxCopilot/Services/Agent/ContextCondenser.cs`
- `SplitCompactionWindow()`를 추가해 recent window를 자를 때 `AgentMessageInvariantHelper.AdjustStartIndexForToolPairs()`를 먼저 적용합니다.
- `MicrocompactOlderMessages`, `SessionMemoryCompactOlderMessages`, `CollapseAndSnipOlderMessages`, `SummarizeOldMessagesAsync`가 모두 같은 split helper를 사용하게 바꿨습니다.
- 결과적으로 recent window 안에 `tool_result`가 남아 있는데 대응 `tool_use`는 old window로 밀려 요약/압축돼 버리는 상황을 줄였습니다.
- 기대 효과
- 자동 압축 직후 다음 API 호출에서 orphan `tool_result`로 인한 pairing 오류가 줄어듭니다.
- query view 보정과 compaction split 보정이 같은 규칙을 쓰므로, 저장 상태와 전송 상태 사이의 불일치가 줄어듭니다.

View File

@@ -0,0 +1,122 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다.
/// </summary>
internal static class AgentMessageInvariantHelper
{
public static int AdjustStartIndexForToolPairs(
IReadOnlyList<ChatMessage> messages,
int startIndex,
out int preservedToolPairs)
{
preservedToolPairs = 0;
if (startIndex <= 0)
return startIndex;
var toolResultIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < messages.Count; i++)
{
if (TryGetToolResultId(messages[i], out var toolResultId))
toolResultIds.Add(toolResultId);
}
if (toolResultIds.Count == 0)
return startIndex;
var toolUseIdsInWindow = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < messages.Count; i++)
{
foreach (var toolUseId in EnumerateToolUseIds(messages[i]))
toolUseIdsInWindow.Add(toolUseId);
}
toolResultIds.ExceptWith(toolUseIdsInWindow);
if (toolResultIds.Count == 0)
return startIndex;
var adjustedStart = startIndex;
for (var i = startIndex - 1; i >= 0 && toolResultIds.Count > 0; i--)
{
var foundAny = false;
foreach (var toolUseId in EnumerateToolUseIds(messages[i]))
{
if (!toolResultIds.Remove(toolUseId))
continue;
preservedToolPairs++;
foundAny = true;
}
if (foundAny)
adjustedStart = i;
}
return adjustedStart;
}
public static bool TryGetToolResultId(ChatMessage message, out string toolResultId)
{
toolResultId = "";
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);
toolResultId = doc.RootElement.TryGetProperty("tool_use_id", out var idEl)
? idEl.GetString() ?? ""
: "";
return !string.IsNullOrWhiteSpace(toolResultId);
}
catch
{
return false;
}
}
public static IEnumerable<string> EnumerateToolUseIds(ChatMessage message)
{
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
yield break;
var content = message.Content ?? "";
if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal))
yield break;
JsonDocument? doc = null;
try
{
doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array)
yield break;
foreach (var block in blocksEl.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl)
|| !string.Equals(typeEl.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (block.TryGetProperty("id", out var idEl))
{
var toolUseId = idEl.GetString();
if (!string.IsNullOrWhiteSpace(toolUseId))
yield return toolUseId!;
}
}
}
finally
{
doc?.Dispose();
}
}
}

View File

@@ -47,7 +47,7 @@ public static class AgentQueryContextBuilder
} }
var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied); var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied);
var adjustedStartIndex = AdjustStartIndexForToolPairs(sourceMessages, startIndex, out var preservedToolPairs); var adjustedStartIndex = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(sourceMessages, startIndex, out var preservedToolPairs);
var toolPairExpanded = adjustedStartIndex < startIndex; var toolPairExpanded = adjustedStartIndex < startIndex;
var windowMessages = new List<ChatMessage>(sourceMessages.Count); var windowMessages = new List<ChatMessage>(sourceMessages.Count);
@@ -112,120 +112,6 @@ public static class AgentQueryContextBuilder
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal); || content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
} }
private static int AdjustStartIndexForToolPairs(
IReadOnlyList<ChatMessage> sourceMessages,
int startIndex,
out int preservedToolPairs)
{
preservedToolPairs = 0;
if (startIndex <= 0)
return startIndex;
var toolResultIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < sourceMessages.Count; i++)
{
if (!TryGetToolResultId(sourceMessages[i], out var toolResultId))
continue;
toolResultIds.Add(toolResultId);
}
if (toolResultIds.Count == 0)
return startIndex;
var toolUseIdsInWindow = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = startIndex; i < sourceMessages.Count; i++)
{
foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i]))
toolUseIdsInWindow.Add(toolUseId);
}
toolResultIds.ExceptWith(toolUseIdsInWindow);
if (toolResultIds.Count == 0)
return startIndex;
var adjustedStart = startIndex;
for (var i = startIndex - 1; i >= 0 && toolResultIds.Count > 0; i--)
{
var foundAny = false;
foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i]))
{
if (!toolResultIds.Remove(toolUseId))
continue;
preservedToolPairs++;
foundAny = true;
}
if (foundAny)
adjustedStart = i;
}
return adjustedStart;
}
private static bool TryGetToolResultId(ChatMessage message, out string toolResultId)
{
toolResultId = "";
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);
toolResultId = doc.RootElement.TryGetProperty("tool_use_id", out var idEl)
? idEl.GetString() ?? ""
: "";
return !string.IsNullOrWhiteSpace(toolResultId);
}
catch
{
return false;
}
}
private static IEnumerable<string> EnumerateToolUseIds(ChatMessage message)
{
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
yield break;
var content = message.Content ?? "";
if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal))
yield break;
JsonDocument? doc = null;
try
{
doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array)
yield break;
foreach (var block in blocksEl.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl)
|| !string.Equals(typeEl.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (block.TryGetProperty("id", out var idEl))
{
var toolUseId = idEl.GetString();
if (!string.IsNullOrWhiteSpace(toolUseId))
yield return toolUseId!;
}
}
}
finally
{
doc?.Dispose();
}
}
private static int ApplyOldToolResultBudget(List<ChatMessage> messages) private static int ApplyOldToolResultBudget(List<ChatMessage> messages)
{ {
var nonSystemIndexes = messages var nonSystemIndexes = messages
@@ -244,7 +130,7 @@ public static class AgentQueryContextBuilder
for (var i = 0; i < protectedStart; i++) for (var i = 0; i < protectedStart; i++)
{ {
var message = messages[i]; var message = messages[i];
if (!TryGetToolResultId(message, out _)) if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _))
continue; continue;
var content = message.Content ?? ""; var content = message.Content ?? "";

View File

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