압축 경계에서도 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:
122
src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs
Normal file
122
src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다.
|
||||
/// </summary>
|
||||
internal static class AgentMessageInvariantHelper
|
||||
{
|
||||
public static int AdjustStartIndexForToolPairs(
|
||||
IReadOnlyList<ChatMessage> messages,
|
||||
int startIndex,
|
||||
out int preservedToolPairs)
|
||||
{
|
||||
preservedToolPairs = 0;
|
||||
if (startIndex <= 0)
|
||||
return startIndex;
|
||||
|
||||
var toolResultIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = startIndex; i < messages.Count; i++)
|
||||
{
|
||||
if (TryGetToolResultId(messages[i], out var toolResultId))
|
||||
toolResultIds.Add(toolResultId);
|
||||
}
|
||||
|
||||
if (toolResultIds.Count == 0)
|
||||
return startIndex;
|
||||
|
||||
var toolUseIdsInWindow = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = startIndex; i < messages.Count; i++)
|
||||
{
|
||||
foreach (var toolUseId in EnumerateToolUseIds(messages[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(messages[i]))
|
||||
{
|
||||
if (!toolResultIds.Remove(toolUseId))
|
||||
continue;
|
||||
|
||||
preservedToolPairs++;
|
||||
foundAny = true;
|
||||
}
|
||||
|
||||
if (foundAny)
|
||||
adjustedStart = i;
|
||||
}
|
||||
|
||||
return adjustedStart;
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
|
||||
public 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -43,6 +43,14 @@ public static class ContextCondenser
|
||||
private const int AutoCompactBufferTokens = 13_000;
|
||||
private const int SummaryReserveTokens = 20_000;
|
||||
|
||||
private sealed class CompactionWindow
|
||||
{
|
||||
public ChatMessage? SystemMessage { get; init; }
|
||||
public required List<ChatMessage> OldMessages { get; init; }
|
||||
public required List<ChatMessage> RecentMessages { get; init; }
|
||||
public int PreservedToolPairs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>모델별 입력 토큰 한도 (대략).</summary>
|
||||
private static int GetModelInputLimit(string service, string model)
|
||||
{
|
||||
@@ -212,6 +220,26 @@ public static class ContextCondenser
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CompactionWindow SplitCompactionWindow(List<ChatMessage> messages, int extraTrailingProtection)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
var trailingCount = Math.Min(RecentKeepCount + Math.Max(0, extraTrailingProtection), nonSystemMessages.Count);
|
||||
var splitStart = Math.Max(0, nonSystemMessages.Count - trailingCount);
|
||||
var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
|
||||
nonSystemMessages,
|
||||
splitStart,
|
||||
out var preservedToolPairs);
|
||||
|
||||
return new CompactionWindow
|
||||
{
|
||||
SystemMessage = systemMsg,
|
||||
OldMessages = nonSystemMessages.Take(adjustedStart).ToList(),
|
||||
RecentMessages = nonSystemMessages.Skip(adjustedStart).ToList(),
|
||||
PreservedToolPairs = preservedToolPairs,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1단계: 대용량 도구 결과를 축약합니다.
|
||||
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
|
||||
@@ -286,13 +314,10 @@ public static class ContextCondenser
|
||||
/// </summary>
|
||||
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
var window = SplitCompactionWindow(messages, extraTrailingProtection: 2);
|
||||
if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0);
|
||||
var oldMessages = window.OldMessages;
|
||||
var recentMessages = window.RecentMessages;
|
||||
|
||||
var rewritten = new List<ChatMessage>(oldMessages.Count);
|
||||
bool changed = false;
|
||||
@@ -347,7 +372,7 @@ public static class ContextCondenser
|
||||
if (!changed) return (0, 0);
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return (boundaryCount, singleCount);
|
||||
@@ -355,13 +380,10 @@ public static class ContextCondenser
|
||||
|
||||
private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 3) return 0;
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
var window = SplitCompactionWindow(messages, extraTrailingProtection: 3);
|
||||
if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return 0;
|
||||
var oldMessages = window.OldMessages;
|
||||
var recentMessages = window.RecentMessages;
|
||||
|
||||
var candidates = oldMessages
|
||||
.Select((message, index) => new { message, index })
|
||||
@@ -415,7 +437,7 @@ public static class ContextCondenser
|
||||
}
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return candidates.Count;
|
||||
@@ -437,13 +459,10 @@ public static class ContextCondenser
|
||||
|
||||
private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
var window = SplitCompactionWindow(messages, extraTrailingProtection: 2);
|
||||
if (window.OldMessages.Count == 0 || window.RecentMessages.Count <= RecentKeepCount) return (0, 0);
|
||||
var oldMessages = window.OldMessages;
|
||||
var recentMessages = window.RecentMessages;
|
||||
|
||||
var rewritten = new List<ChatMessage>(oldMessages.Count);
|
||||
int snippedCount = 0;
|
||||
@@ -494,7 +513,7 @@ public static class ContextCondenser
|
||||
if (snippedCount == 0 && collapsedCount == 0) return (0, 0);
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
if (window.SystemMessage != null) messages.Add(window.SystemMessage);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return (snippedCount, collapsedCount);
|
||||
@@ -680,13 +699,10 @@ public static class ContextCondenser
|
||||
private static async Task<bool> SummarizeOldMessagesAsync(
|
||||
List<ChatMessage> messages, ILlmService llm, CancellationToken ct)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var systemCount = systemMsg != null ? 1 : 0;
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
var window = SplitCompactionWindow(messages, extraTrailingProtection: 0);
|
||||
var systemMsg = window.SystemMessage;
|
||||
var oldMessages = window.OldMessages;
|
||||
var recentMessages = window.RecentMessages;
|
||||
|
||||
if (oldMessages.Count < 3) return false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user