컨텍스트 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:
@@ -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 메시지만 추가 절단하도록 역할을 분리했습니다.
|
||||
|
||||
@@ -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`의 토큰 사용량이 더 예측 가능해집니다.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
132
src/AxCopilot/Services/Agent/AgentToolResultBudget.cs
Normal file
132
src/AxCopilot/Services/Agent/AgentToolResultBudget.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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개는 유지하고, 나머지를 요약으로 교체합니다.
|
||||
|
||||
Reference in New Issue
Block a user