From 0a2e5b2d4916c6879f0563396a34e0be15978948 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 12 Apr 2026 21:40:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=95=EC=B6=95=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8F=84=20tool=20pair=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=EC=8B=9D=EC=9D=84=20=EC=9C=A0=EC=A7=80=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 + docs/DEVELOPMENT.md | 14 ++ .../Agent/AgentMessageInvariantHelper.cs | 122 ++++++++++++++++++ .../Agent/AgentQueryContextBuilder.cs | 118 +---------------- .../Services/Agent/ContextCondenser.cs | 78 ++++++----- 5 files changed, 190 insertions(+), 147 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs 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;