컨텍스트 전송 뷰와 압축 트리거를 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:
2026-04-12 21:36:50 +09:00
parent 9175dfe657
commit 0f83dc802c
6 changed files with 844 additions and 42 deletions

View 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(),
};
}
}