압축 경계에서도 tool pair 불변식을 유지하도록 보강

claw-code의 sessionMemoryCompact 흐름을 참고해 AX Agent가 recent window를 자를 때도 tool_use/tool_result 짝이 끊기지 않도록 보정 범위를 넓혔다.

- AgentMessageInvariantHelper를 추가해 tool_result와 assistant _tool_use_blocks 대응 관계를 공용 로직으로 정리

- AgentQueryContextBuilder는 공용 helper를 사용하도록 단순화

- ContextCondenser의 microcompact, session memory compact, snip, 요약 단계가 모두 같은 split helper로 recent window를 계산하도록 조정

- compaction split 시 recent window 안의 tool_result가 old window의 tool_use를 잃는 상황을 줄여 API pairing 안정성을 높임

- README.md, docs/DEVELOPMENT.md에 2026-04-12 21:39 (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:40:22 +09:00
parent 0f83dc802c
commit 0a2e5b2d49
5 changed files with 190 additions and 147 deletions

View File

@@ -47,7 +47,7 @@ public static class AgentQueryContextBuilder
}
var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied);
var adjustedStartIndex = 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);
@@ -112,120 +112,6 @@ public static class AgentQueryContextBuilder
|| 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
@@ -244,7 +130,7 @@ public static class AgentQueryContextBuilder
for (var i = 0; i < protectedStart; i++)
{
var message = messages[i];
if (!TryGetToolResultId(message, out _))
if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out _))
continue;
var content = message.Content ?? "";