컨텍스트 전송 뷰와 압축 트리거를 claw-code 기준으로 정리
claw-code의 query.ts, autoCompact.ts, sessionMemoryCompact.ts 흐름을 참고해 AX Agent의 컨텍스트 관리와 압축 동작을 더 가깝게 맞췄다. - AgentQueryContextBuilder를 추가해 저장된 전체 대화와 실제 LLM 전송용 query view를 분리 - compact boundary 이후만 전송하고 tool_result/tool_use 짝이 끊기지 않도록 start index를 보정 - 오래된 tool_result는 query view에서만 별도 budget으로 축약하도록 조정 - ContextCondenser의 자동 압축 시작점을 effective context window, summary reserve, buffer 기준으로 재계산 - 미사용 입력 높이 캐시 필드를 제거해 빌드 경고를 해소 - README.md, docs/DEVELOPMENT.md에 2026-04-12 21:34 (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:
334
src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
Normal file
334
src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class AgentQueryContextWindowResult
|
||||
{
|
||||
public required List<ChatMessage> Messages { get; init; }
|
||||
public int SourceMessageCount { get; init; }
|
||||
public int ViewMessageCount { get; init; }
|
||||
public int WindowStartIndex { get; init; }
|
||||
public bool BoundaryApplied { get; init; }
|
||||
public bool ToolPairExpanded { get; init; }
|
||||
public int PreservedToolPairCount { get; init; }
|
||||
public int TruncatedToolResultCount { get; init; }
|
||||
public int TokensBeforeBudget { get; init; }
|
||||
public int TokensAfterBudget { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// claude-code의 messagesForQuery 계층처럼, 저장된 전체 대화와
|
||||
/// 실제 LLM에 전송할 컨텍스트 뷰를 분리합니다.
|
||||
/// </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)
|
||||
{
|
||||
if (sourceMessages.Count == 0)
|
||||
{
|
||||
return new AgentQueryContextWindowResult
|
||||
{
|
||||
Messages = new List<ChatMessage>(),
|
||||
SourceMessageCount = 0,
|
||||
ViewMessageCount = 0,
|
||||
WindowStartIndex = 0,
|
||||
BoundaryApplied = false,
|
||||
ToolPairExpanded = false,
|
||||
PreservedToolPairCount = 0,
|
||||
TruncatedToolResultCount = 0,
|
||||
TokensBeforeBudget = 0,
|
||||
TokensAfterBudget = 0,
|
||||
};
|
||||
}
|
||||
|
||||
var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied);
|
||||
var adjustedStartIndex = AdjustStartIndexForToolPairs(sourceMessages, startIndex, out var preservedToolPairs);
|
||||
var toolPairExpanded = adjustedStartIndex < startIndex;
|
||||
|
||||
var windowMessages = new List<ChatMessage>(sourceMessages.Count);
|
||||
for (var i = 0; i < sourceMessages.Count; i++)
|
||||
{
|
||||
var include = string.Equals(sourceMessages[i].Role, "system", StringComparison.OrdinalIgnoreCase)
|
||||
|| i >= adjustedStartIndex;
|
||||
if (!include)
|
||||
continue;
|
||||
|
||||
windowMessages.Add(CloneMessage(sourceMessages[i]));
|
||||
}
|
||||
|
||||
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
|
||||
var truncatedToolResults = ApplyOldToolResultBudget(windowMessages);
|
||||
var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
|
||||
|
||||
return new AgentQueryContextWindowResult
|
||||
{
|
||||
Messages = windowMessages,
|
||||
SourceMessageCount = sourceMessages.Count,
|
||||
ViewMessageCount = windowMessages.Count,
|
||||
WindowStartIndex = adjustedStartIndex,
|
||||
BoundaryApplied = boundaryApplied,
|
||||
ToolPairExpanded = toolPairExpanded,
|
||||
PreservedToolPairCount = preservedToolPairs,
|
||||
TruncatedToolResultCount = truncatedToolResults,
|
||||
TokensBeforeBudget = tokensBeforeBudget,
|
||||
TokensAfterBudget = tokensAfterBudget,
|
||||
};
|
||||
}
|
||||
|
||||
private static int FindWindowStartIndex(IReadOnlyList<ChatMessage> sourceMessages, out bool boundaryApplied)
|
||||
{
|
||||
for (var i = sourceMessages.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (IsQueryBoundaryMarker(sourceMessages[i]))
|
||||
{
|
||||
boundaryApplied = true;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
boundaryApplied = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsQueryBoundaryMarker(ChatMessage message)
|
||||
{
|
||||
var metaKind = message.MetaKind ?? "";
|
||||
if (metaKind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
||||
|| metaKind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
||||
|| metaKind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var content = message.Content ?? "";
|
||||
return content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int AdjustStartIndexForToolPairs(
|
||||
IReadOnlyList<ChatMessage> sourceMessages,
|
||||
int startIndex,
|
||||
out int preservedToolPairs)
|
||||
{
|
||||
preservedToolPairs = 0;
|
||||
if (startIndex <= 0)
|
||||
return startIndex;
|
||||
|
||||
var toolResultIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = startIndex; i < sourceMessages.Count; i++)
|
||||
{
|
||||
if (!TryGetToolResultId(sourceMessages[i], out var toolResultId))
|
||||
continue;
|
||||
|
||||
toolResultIds.Add(toolResultId);
|
||||
}
|
||||
|
||||
if (toolResultIds.Count == 0)
|
||||
return startIndex;
|
||||
|
||||
var toolUseIdsInWindow = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = startIndex; i < sourceMessages.Count; i++)
|
||||
{
|
||||
foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i]))
|
||||
toolUseIdsInWindow.Add(toolUseId);
|
||||
}
|
||||
|
||||
toolResultIds.ExceptWith(toolUseIdsInWindow);
|
||||
if (toolResultIds.Count == 0)
|
||||
return startIndex;
|
||||
|
||||
var adjustedStart = startIndex;
|
||||
for (var i = startIndex - 1; i >= 0 && toolResultIds.Count > 0; i--)
|
||||
{
|
||||
var foundAny = false;
|
||||
foreach (var toolUseId in EnumerateToolUseIds(sourceMessages[i]))
|
||||
{
|
||||
if (!toolResultIds.Remove(toolUseId))
|
||||
continue;
|
||||
|
||||
preservedToolPairs++;
|
||||
foundAny = true;
|
||||
}
|
||||
|
||||
if (foundAny)
|
||||
adjustedStart = i;
|
||||
}
|
||||
|
||||
return adjustedStart;
|
||||
}
|
||||
|
||||
private static bool TryGetToolResultId(ChatMessage message, out string toolResultId)
|
||||
{
|
||||
toolResultId = "";
|
||||
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var content = message.Content ?? "";
|
||||
if (!content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
toolResultId = doc.RootElement.TryGetProperty("tool_use_id", out var idEl)
|
||||
? idEl.GetString() ?? ""
|
||||
: "";
|
||||
return !string.IsNullOrWhiteSpace(toolResultId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateToolUseIds(ChatMessage message)
|
||||
{
|
||||
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
yield break;
|
||||
|
||||
var content = message.Content ?? "";
|
||||
if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal))
|
||||
yield break;
|
||||
|
||||
JsonDocument? doc = null;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(content);
|
||||
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array)
|
||||
yield break;
|
||||
|
||||
foreach (var block in blocksEl.EnumerateArray())
|
||||
{
|
||||
if (!block.TryGetProperty("type", out var typeEl)
|
||||
|| !string.Equals(typeEl.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
var toolUseId = idEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(toolUseId))
|
||||
yield return toolUseId!;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
doc?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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 (!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
|
||||
{
|
||||
MsgId = source.MsgId,
|
||||
Role = source.Role,
|
||||
Content = contentOverride ?? source.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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user