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개는 유지하고, 나머지를 요약으로 교체합니다.