컨텍스트 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

@@ -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