오래된 tool_result를 시간 기반으로 정리하고 compact 직후 안내를 단순화

- 마지막 assistant 이후 긴 휴지기가 있으면 오래된 tool_result를 cleared marker로 교체하는 time-based microcompact 성격의 분기 추가
- compact 직후 첫 턴에서 운영성 thinking 문구를 다시 띄우지 않도록 AgentLoopCompactionPolicy 정리
- README와 DEVELOPMENT 문서에 2026-04-12 22:11 (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:50:22 +09:00
parent 1dd10e0664
commit bdd4444deb
5 changed files with 108 additions and 12 deletions

View File

@@ -19,17 +19,6 @@ public partial class AgentLoopService
runState.PendingPostCompactionTurn = false;
runState.PostCompactionTurnCounter++;
SyncRunPostCompactionState(runState);
var stage = string.IsNullOrWhiteSpace(runState.LastCompactionStageSummary)
? "기본"
: runState.LastCompactionStageSummary;
var saved = runState.LastCompactionSavedTokens > 0
? $" · {Services.TokenEstimator.Format(runState.LastCompactionSavedTokens)} tokens 절감"
: "";
EmitEvent(
AgentEventType.Thinking,
"",
$"compact 이후 {runState.PostCompactionTurnCounter}번째 턴 · {stage}{saved}");
}
private void SyncRunPostCompactionState(RunState runState)
@@ -59,7 +48,7 @@ public partial class AgentLoopService
|| summary.StartsWith("컨텍스트 압축 완료", StringComparison.Ordinal);
}
private string BuildLoopToolResultMessage(LlmService.ContentBlock call, ToolResult result, RunState runState)
private string BuildLoopToolResultMessage(ContentBlock call, ToolResult result, RunState runState)
{
var output = result.Output ?? "";
if (!ShouldCompactToolResultForPostCompactionTurn(runState, call.ToolName, output))

View File

@@ -106,6 +106,32 @@ public static class AgentToolResultBudget
}
}
public static string CreateClearedToolResultJson(string json, string clearedContent)
{
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() ?? "" : "";
return JsonSerializer.Serialize(new
{
type = "tool_result",
tool_use_id = toolUseId,
tool_name = toolName,
content = clearedContent
});
}
catch
{
return clearedContent;
}
}
private static ChatMessage CloneMessage(ChatMessage source, string content)
{
return new ChatMessage

View File

@@ -42,6 +42,9 @@ public static class ContextCondenser
private const int RecentKeepCount = 6;
private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000;
private const int TimeBasedToolResultGapMinutes = 20;
private const int TimeBasedKeepRecentToolResults = 1;
private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다.";
private sealed class CompactionWindow
{
@@ -119,8 +122,16 @@ public static class ContextCondenser
var currentTokens = TokenEstimator.EstimateMessages(messages);
result.BeforeTokens = currentTokens;
if (ApplyTimeBasedToolResultCompaction(messages, out var clearedToolResults))
{
result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})");
currentTokens = TokenEstimator.EstimateMessages(messages);
}
if (!force && currentTokens < threshold)
{
result.Changed = result.AppliedStages.Count > 0;
result.AfterTokens = currentTokens;
return result;
}
@@ -240,6 +251,55 @@ public static class ContextCondenser
};
}
private static bool ApplyTimeBasedToolResultCompaction(List<ChatMessage> messages, out int clearedCount)
{
clearedCount = 0;
var lastAssistant = messages
.Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.Timestamp)
.FirstOrDefault();
if (lastAssistant?.Timestamp is not DateTime lastAssistantAt)
return false;
var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes;
if (!double.IsFinite(gapMinutes) || gapMinutes < TimeBasedToolResultGapMinutes)
return false;
var candidateIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => AgentMessageInvariantHelper.TryGetToolResultId(x.message, out _))
.Select(x => x.index)
.ToList();
if (candidateIndexes.Count <= TimeBasedKeepRecentToolResults)
return false;
var keepSet = candidateIndexes
.TakeLast(Math.Max(1, TimeBasedKeepRecentToolResults))
.ToHashSet();
foreach (var index in candidateIndexes)
{
if (keepSet.Contains(index))
continue;
var message = messages[index];
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content))
continue;
var cleared = AgentToolResultBudget.CreateClearedToolResultJson(content, TimeBasedClearedToolResultMessage);
if (string.Equals(cleared, content, StringComparison.Ordinal))
continue;
messages[index] = CloneWithContent(message, cleared);
clearedCount++;
}
return clearedCount > 0;
}
/// <summary>
/// 1단계: 오래된 tool_result는 aggregate budget 기준으로 먼저 줄이고,
/// 그 외 긴 assistant/user 메시지는 경량 절단합니다.