diff --git a/README.md b/README.md index 7106277..0ad6ce6 100644 --- a/README.md +++ b/README.md @@ -1633,3 +1633,8 @@ MIT License - [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 오류를 만드는 상황을 줄일 수 있습니다. +- 업데이트: 2026-04-12 22:02 (KST) + - `claw-code`의 `applyToolResultBudget` 방향을 따라, AX도 query view와 압축 1단계가 같은 `tool_result` 예산 규칙을 공유하도록 정리했습니다. + - [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)를 추가해 오래된 `tool_result`를 최근 보호 구간과 aggregate budget 기준으로 공용 축약하도록 만들었습니다. + - [AgentQueryContextBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs)는 query view에서 이 공용 helper를 사용하게 바뀌어, 전송 직전 budget과 축약 결과가 압축 본체와 같은 규칙을 따릅니다. + - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 1단계에서 `tool_result`를 별도 공용 budget으로 먼저 줄이고, 그 외 긴 assistant/user 메시지만 추가 절단하도록 역할을 분리했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d03b5d8..aa8289b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -599,3 +599,19 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - 자동 압축 직후 다음 API 호출에서 orphan `tool_result`로 인한 pairing 오류가 줄어듭니다. - query view 보정과 compaction split 보정이 같은 규칙을 쓰므로, 저장 상태와 전송 상태 사이의 불일치가 줄어듭니다. +## query view / 압축 1단계 tool_result budget 통일 (2026-04-12 22:02 KST) + +- `claw-code`의 `toolResultStorage.applyToolResultBudget()`처럼, 오래된 `tool_result`를 줄이는 규칙을 query view와 본체 압축 단계가 공용으로 쓰도록 정리했습니다. +- `src/AxCopilot/Services/Agent/AgentToolResultBudget.cs` + - 최근 보호 구간과 aggregate character budget을 함께 적용하는 공용 helper를 추가했습니다. + - `tool_result` JSON 축약도 같은 helper 안에서 처리해 query view와 compaction이 같은 축약 결과를 사용하도록 맞췄습니다. +- `src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs` + - 전송 전용 query view가 이제 `AgentToolResultBudget.Apply()`를 사용해 오래된 `tool_result`를 동일 규칙으로 줄입니다. + - compact boundary 이후 window 계산과 pair invariant 보정은 유지하면서, budget 적용 결과만 공용화했습니다. +- `src/AxCopilot/Services/Agent/ContextCondenser.cs` + - 1단계 `TruncateToolResults()`가 오래된 `tool_result`를 직접 개별 문자열 규칙으로 자르지 않고, 공용 budget helper를 먼저 적용하도록 바꿨습니다. + - 이후 단계는 긴 assistant `_tool_use_blocks`와 일반 긴 텍스트만 추가 절단해 역할이 겹치지 않게 정리했습니다. +- 기대 효과 + - query view와 저장 상태 사이에서 `tool_result` 축약 기준이 달라 생기던 흔들림이 줄어듭니다. + - 같은 세션을 반복 호출할 때 오래된 `tool_result`의 토큰 사용량이 더 예측 가능해집니다. + diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs index 3059632..0ff30fd 100644 --- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using AxCopilot.Models; namespace AxCopilot.Services.Agent; @@ -18,14 +17,11 @@ public sealed class AgentQueryContextWindowResult } /// -/// claude-code의 messagesForQuery 계층처럼, 저장된 전체 대화와 -/// 실제 LLM에 전송할 컨텍스트 뷰를 분리합니다. +/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다. /// public static class AgentQueryContextBuilder { private const int ProtectedRecentNonSystemMessages = 8; - private const int OldToolResultSoftCharLimit = 900; - private const int OldToolResultAggregateBudgetChars = 7_500; public static AgentQueryContextWindowResult Build(IReadOnlyList sourceMessages) { @@ -47,7 +43,10 @@ public static class AgentQueryContextBuilder } var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied); - var adjustedStartIndex = AgentMessageInvariantHelper.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); @@ -62,7 +61,7 @@ public static class AgentQueryContextBuilder } var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages); - var truncatedToolResults = ApplyOldToolResultBudget(windowMessages); + var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages); var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages); return new AgentQueryContextWindowResult @@ -74,7 +73,7 @@ public static class AgentQueryContextBuilder BoundaryApplied = boundaryApplied, ToolPairExpanded = toolPairExpanded, PreservedToolPairCount = preservedToolPairs, - TruncatedToolResultCount = truncatedToolResults, + TruncatedToolResultCount = budgetResult.TruncatedCount, TokensBeforeBudget = tokensBeforeBudget, TokensAfterBudget = tokensAfterBudget, }; @@ -112,88 +111,6 @@ public static class AgentQueryContextBuilder || content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal); } - private static int ApplyOldToolResultBudget(List messages) - { - var nonSystemIndexes = messages - .Select((message, index) => new { message, index }) - .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.index) - .ToList(); - - if (nonSystemIndexes.Count <= ProtectedRecentNonSystemMessages) - return 0; - - var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - ProtectedRecentNonSystemMessages)]; - var spentChars = 0; - var truncatedCount = 0; - - for (var i = 0; i < protectedStart; i++) - { - var message = messages[i]; - if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _)) - continue; - - var content = message.Content ?? ""; - if (string.IsNullOrWhiteSpace(content)) - continue; - - spentChars += content.Length; - if (content.Length <= OldToolResultSoftCharLimit && spentChars <= OldToolResultAggregateBudgetChars) - continue; - - var truncated = TruncateToolResultJson(content); - if (string.Equals(truncated, content, StringComparison.Ordinal)) - continue; - - messages[i] = CloneMessage(message, truncated); - truncatedCount++; - } - - return truncatedCount; - } - - private static string TruncateToolResultJson(string json) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - var toolType = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : ""; - if (!string.Equals(toolType, "tool_result", StringComparison.Ordinal)) - return json; - - var toolUseId = root.TryGetProperty("tool_use_id", out var idEl) ? idEl.GetString() ?? "" : ""; - var toolName = root.TryGetProperty("tool_name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var content = root.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : ""; - if (content.Length <= OldToolResultSoftCharLimit) - return json; - - var keepHead = Math.Min(360, content.Length); - var keepTail = Math.Min(220, Math.Max(0, content.Length - keepHead)); - var head = content[..keepHead]; - var tail = keepTail > 0 ? content[^keepTail..] : ""; - var compacted = head + - $"\n...[query-view tool_result 축약: {content.Length:N0}자]...\n" + - tail; - - return JsonSerializer.Serialize(new - { - type = "tool_result", - tool_use_id = toolUseId, - tool_name = toolName, - content = compacted - }); - } - catch - { - if (json.Length <= OldToolResultSoftCharLimit) - return json; - - var head = json[..Math.Min(OldToolResultSoftCharLimit, json.Length)]; - return head + "...[query-view 축약됨]"; - } - } - private static ChatMessage CloneMessage(ChatMessage source, string? contentOverride = null) { return new ChatMessage diff --git a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs new file mode 100644 index 0000000..1a14d3d --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +public sealed class AgentToolResultBudgetResult +{ + public int TruncatedCount { get; set; } + public int ProcessedCount { get; set; } + public int TotalCharsBefore { get; set; } +} + +/// +/// 오래된 tool_result를 query view와 압축 단계에서 같은 기준으로 줄이기 위한 공용 helper. +/// +public static class AgentToolResultBudget +{ + public const int DefaultSoftCharLimit = 900; + public const int DefaultAggregateBudgetChars = 7_500; + + public static AgentToolResultBudgetResult Apply( + List messages, + int protectedRecentNonSystemMessages, + int softCharLimit = DefaultSoftCharLimit, + int aggregateBudgetChars = DefaultAggregateBudgetChars) + { + var result = new AgentToolResultBudgetResult(); + var nonSystemIndexes = messages + .Select((message, index) => new { message, index }) + .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.index) + .ToList(); + + if (nonSystemIndexes.Count <= protectedRecentNonSystemMessages) + return result; + + var protectedStart = nonSystemIndexes[Math.Max(0, nonSystemIndexes.Count - protectedRecentNonSystemMessages)]; + var spentChars = 0; + + for (var i = 0; i < protectedStart; i++) + { + var message = messages[i]; + if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _)) + continue; + + var content = message.Content ?? ""; + if (string.IsNullOrWhiteSpace(content)) + continue; + + result.ProcessedCount++; + result.TotalCharsBefore += content.Length; + spentChars += content.Length; + if (content.Length <= softCharLimit && spentChars <= aggregateBudgetChars) + continue; + + var truncated = TruncateToolResultJson(content, softCharLimit); + if (string.Equals(truncated, content, StringComparison.Ordinal)) + continue; + + messages[i] = CloneMessage(message, truncated); + result.TruncatedCount++; + } + + return result; + } + + public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var toolType = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : ""; + if (!string.Equals(toolType, "tool_result", StringComparison.Ordinal)) + return json; + + var toolUseId = root.TryGetProperty("tool_use_id", out var idEl) ? idEl.GetString() ?? "" : ""; + var toolName = root.TryGetProperty("tool_name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var content = root.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : ""; + if (content.Length <= softCharLimit) + return json; + + var keepHead = Math.Min(360, content.Length); + var keepTail = Math.Min(220, Math.Max(0, content.Length - keepHead)); + var head = content[..keepHead]; + var tail = keepTail > 0 ? content[^keepTail..] : ""; + var compacted = head + + $"\n...[tool_result 축약: {content.Length:N0}자]...\n" + + tail; + + return JsonSerializer.Serialize(new + { + type = "tool_result", + tool_use_id = toolUseId, + tool_name = toolName, + content = compacted + }); + } + catch + { + if (json.Length <= softCharLimit) + return json; + + var head = json[..Math.Min(softCharLimit, json.Length)]; + return head + "...[tool_result 축약]"; + } + } + + private static ChatMessage CloneMessage(ChatMessage source, string content) + { + return new ChatMessage + { + MsgId = source.MsgId, + Role = source.Role, + Content = content, + Timestamp = source.Timestamp, + MetaKind = source.MetaKind, + MetaRunId = source.MetaRunId, + Feedback = source.Feedback, + ResponseElapsedMs = source.ResponseElapsedMs, + PromptTokens = source.PromptTokens, + CompletionTokens = source.CompletionTokens, + AttachedFiles = source.AttachedFiles?.ToList(), + Images = source.Images?.Select(image => new ImageAttachment + { + Base64 = image.Base64, + MimeType = image.MimeType, + FileName = image.FileName, + }).ToList(), + }; + } +} diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 2bba9bc..67f412a 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -241,14 +241,14 @@ public static class ContextCondenser } /// - /// 1단계: 대용량 도구 결과를 축약합니다. - /// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다. + /// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고, + /// 그 외 긴 assistant/user 메시지는 경량 절단합니다. /// private static bool TruncateToolResults(List messages) { - bool truncated = false; + var budgetResult = AgentToolResultBudget.Apply(messages, RecentKeepCount); + bool truncated = budgetResult.TruncatedCount > 0; - // 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지) var cutoff = Math.Max(0, messages.Count - RecentKeepCount); for (int i = 0; i < cutoff; i++) @@ -256,25 +256,20 @@ public static class ContextCondenser var msg = messages[i]; if (msg.Content == null) continue; - // tool_result 메시지의 대용량 출력 축약 - if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars) + if (AgentMessageInvariantHelper.TryGetToolResultId(msg, out _)) { - // JSON 구조를 유지하되 output 부분만 축약 - messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content)); - truncated = true; + continue; } - // assistant의 도구 호출 블록 내 긴 텍스트도 축약 - else if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 && + + 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( @@ -658,40 +653,6 @@ public static class ContextCondenser }; } - /// tool_result JSON 내의 output 값을 축약합니다. - 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; - } - /// /// 2단계: 오래된 메시지를 LLM으로 요약합니다. /// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.