컨텍스트 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user