컨텍스트 tool_result 예산 규칙을 공용화해 query view와 압축 단계 정합성 강화

- AgentToolResultBudget helper를 추가해 오래된 tool_result를 최근 보호 구간과 aggregate budget 기준으로 공용 축약
- AgentQueryContextBuilder와 ContextCondenser가 같은 budget 규칙을 사용하도록 정리
- README와 DEVELOPMENT 문서에 2026-04-12 22:02 (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:47:02 +09:00
parent 0a2e5b2d49
commit 1dd10e0664
5 changed files with 168 additions and 137 deletions

View File

@@ -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 메시지만 추가 절단하도록 역할을 분리했습니다.

View File

@@ -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`의 토큰 사용량이 더 예측 가능해집니다.

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
@@ -18,14 +17,11 @@ public sealed class AgentQueryContextWindowResult
}
/// <summary>
/// claude-code의 messagesForQuery 계층처럼, 저장된 전체 대화와
/// 실제 LLM에 전송할 컨텍스트 뷰를 분리합니다.
/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다.
/// </summary>
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<ChatMessage> 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<ChatMessage>(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<ChatMessage> 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

View File

@@ -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; }
}
/// <summary>
/// 오래된 tool_result를 query view와 압축 단계에서 같은 기준으로 줄이기 위한 공용 helper.
/// </summary>
public static class AgentToolResultBudget
{
public const int DefaultSoftCharLimit = 900;
public const int DefaultAggregateBudgetChars = 7_500;
public static AgentToolResultBudgetResult Apply(
List<ChatMessage> 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(),
};
}
}

View File

@@ -241,14 +241,14 @@ public static class ContextCondenser
}
/// <summary>
/// 1단계: 대용량 도구 결과를 축약합니다.
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
/// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고,
/// 그 외 긴 assistant/user 메시지는 경량 절단합니다.
/// </summary>
private static bool TruncateToolResults(List<ChatMessage> 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
};
}
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
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;
}
/// <summary>
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.