diff --git a/README.md b/README.md
index 0c30b1a..7106277 100644
--- a/README.md
+++ b/README.md
@@ -1628,3 +1628,8 @@ MIT License
- 같은 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 호출에 사용하도록 바꿨습니다.
- [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 오류를 만드는 상황을 줄일 수 있습니다.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index aef6f3a..d03b5d8 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -585,3 +585,17 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- 부가 정리
- `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 보정이 같은 규칙을 쓰므로, 저장 상태와 전송 상태 사이의 불일치가 줄어듭니다.
+
diff --git a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs
new file mode 100644
index 0000000..1e9e412
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using AxCopilot.Models;
+
+namespace AxCopilot.Services.Agent;
+
+///
+/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다.
+///
+internal static class AgentMessageInvariantHelper
+{
+ public static int AdjustStartIndexForToolPairs(
+ IReadOnlyList messages,
+ int startIndex,
+ out int preservedToolPairs)
+ {
+ preservedToolPairs = 0;
+ if (startIndex <= 0)
+ return startIndex;
+
+ var toolResultIds = new HashSet(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(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 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();
+ }
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
index fd3dca3..3059632 100644
--- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
+++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
@@ -47,7 +47,7 @@ public static class AgentQueryContextBuilder
}
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 windowMessages = new List(sourceMessages.Count);
@@ -112,120 +112,6 @@ public static class AgentQueryContextBuilder
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
}
- private static int AdjustStartIndexForToolPairs(
- IReadOnlyList sourceMessages,
- int startIndex,
- out int preservedToolPairs)
- {
- preservedToolPairs = 0;
- if (startIndex <= 0)
- return startIndex;
-
- var toolResultIds = new HashSet(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(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 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 messages)
{
var nonSystemIndexes = messages
@@ -244,7 +130,7 @@ public static class AgentQueryContextBuilder
for (var i = 0; i < protectedStart; i++)
{
var message = messages[i];
- if (!TryGetToolResultId(message, out _))
+ if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _))
continue;
var content = message.Content ?? "";
diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs
index 5e39f64..2bba9bc 100644
--- a/src/AxCopilot/Services/Agent/ContextCondenser.cs
+++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs
@@ -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 OldMessages { get; init; }
+ public required List RecentMessages { get; init; }
+ public int PreservedToolPairs { get; init; }
+ }
+
/// 모델별 입력 토큰 한도 (대략).
private static int GetModelInputLimit(string service, string model)
{
@@ -212,6 +220,26 @@ public static class ContextCondenser
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,
+ };
+ }
+
///
/// 1단계: 대용량 도구 결과를 축약합니다.
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
@@ -286,13 +314,10 @@ public static class ContextCondenser
///
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List 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(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 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 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(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 SummarizeOldMessagesAsync(
List 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;