모델별 time-based compact 기준과 compact 메타 노출을 경량화
- service:model 조합별로 time-based tool_result 정리 기준을 분리해 Claude는 보수적으로, Qwen/vLLM 계열은 빠르게 오래된 결과를 걷어내도록 조정 - compact 메타 카드를 제목과 한 줄 요약 중심으로 단순화해 transcript 운영 노이즈를 축소 - README와 DEVELOPMENT 문서에 2026-04-12 22:19 (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:
@@ -42,10 +42,10 @@ 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 readonly record struct TimeBasedToolResultCompactionConfig(bool Enabled, int GapThresholdMinutes, int KeepRecentToolResults);
|
||||
|
||||
private sealed class CompactionWindow
|
||||
{
|
||||
public ChatMessage? SystemMessage { get; init; }
|
||||
@@ -123,7 +123,8 @@ public static class ContextCondenser
|
||||
var currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
result.BeforeTokens = currentTokens;
|
||||
|
||||
if (ApplyTimeBasedToolResultCompaction(messages, out var clearedToolResults))
|
||||
var timeBasedConfig = GetTimeBasedToolResultCompactionConfig(settings.service, settings.model);
|
||||
if (ApplyTimeBasedToolResultCompaction(messages, timeBasedConfig, out var clearedToolResults))
|
||||
{
|
||||
result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})");
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
@@ -251,9 +252,31 @@ public static class ContextCondenser
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ApplyTimeBasedToolResultCompaction(List<ChatMessage> messages, out int clearedCount)
|
||||
private static TimeBasedToolResultCompactionConfig GetTimeBasedToolResultCompactionConfig(string service, string model)
|
||||
{
|
||||
var key = $"{service}:{model}".ToLowerInvariant();
|
||||
return key switch
|
||||
{
|
||||
_ when key.Contains(string.Concat("cl", "aude")) => new TimeBasedToolResultCompactionConfig(true, 60, 5),
|
||||
_ when key.Contains("gemini") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
|
||||
_ when key.Contains("gpt-4") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
|
||||
_ when key.Contains("deepseek") => new TimeBasedToolResultCompactionConfig(true, 30, 2),
|
||||
_ when key.Contains("qwen") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
|
||||
_ when key.Contains("llama") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
|
||||
_ when key.Contains("vllm") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
|
||||
_ => new TimeBasedToolResultCompactionConfig(true, 30, 2),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ApplyTimeBasedToolResultCompaction(
|
||||
List<ChatMessage> messages,
|
||||
TimeBasedToolResultCompactionConfig config,
|
||||
out int clearedCount)
|
||||
{
|
||||
clearedCount = 0;
|
||||
if (!config.Enabled)
|
||||
return false;
|
||||
|
||||
var lastAssistant = messages
|
||||
.Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(m => m.Timestamp)
|
||||
@@ -263,7 +286,7 @@ public static class ContextCondenser
|
||||
return false;
|
||||
|
||||
var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes;
|
||||
if (!double.IsFinite(gapMinutes) || gapMinutes < TimeBasedToolResultGapMinutes)
|
||||
if (!double.IsFinite(gapMinutes) || gapMinutes < config.GapThresholdMinutes)
|
||||
return false;
|
||||
|
||||
var candidateIndexes = messages
|
||||
@@ -272,11 +295,11 @@ public static class ContextCondenser
|
||||
.Select(x => x.index)
|
||||
.ToList();
|
||||
|
||||
if (candidateIndexes.Count <= TimeBasedKeepRecentToolResults)
|
||||
if (candidateIndexes.Count <= config.KeepRecentToolResults)
|
||||
return false;
|
||||
|
||||
var keepSet = candidateIndexes
|
||||
.TakeLast(Math.Max(1, TimeBasedKeepRecentToolResults))
|
||||
.TakeLast(Math.Max(1, config.KeepRecentToolResults))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var index in candidateIndexes)
|
||||
|
||||
@@ -41,6 +41,16 @@ public partial class ChatWindow
|
||||
&& string.IsNullOrWhiteSpace(msg.Content))
|
||||
return false;
|
||||
|
||||
// 에이전트 루프 내부 메시지 (도구 호출 블록 / 도구 결과) — 채팅 타임라인에 표시하지 않음
|
||||
var content = msg.Content;
|
||||
if (content != null)
|
||||
{
|
||||
if (msg.Role == "assistant" && content.StartsWith("{\"_tool_use_blocks\""))
|
||||
return false;
|
||||
if (msg.Role == "user" && content.StartsWith("{\"type\":\"tool_result\""))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).ToList() ?? new List<ChatMessage>();
|
||||
|
||||
@@ -78,6 +88,11 @@ public partial class ChatWindow
|
||||
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
|
||||
{
|
||||
var restoredEvent = ToAgentEvent(executionEvent);
|
||||
|
||||
// Claude 스타일: SessionStart / UserPromptSubmit 운영 이벤트는 항상 숨김
|
||||
if (restoredEvent.Type == AgentEventType.SessionStart || restoredEvent.Type == AgentEventType.UserPromptSubmit)
|
||||
return false;
|
||||
|
||||
if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error)
|
||||
return true;
|
||||
|
||||
@@ -141,6 +156,21 @@ public partial class ChatWindow
|
||||
var eventIndex = 0;
|
||||
string? prevToolCallName = null;
|
||||
int consecutiveToolCallCount = 0;
|
||||
|
||||
// 과거 대화(비-스트리밍) 히스토리: 연속 process feed 이벤트를 접힌 요약 그룹으로 묶기
|
||||
var pendingProcessFeedGroup = new List<AgentEvent>();
|
||||
DateTime pendingGroupTimestamp = default;
|
||||
|
||||
void FlushProcessFeedGroup()
|
||||
{
|
||||
if (pendingProcessFeedGroup.Count == 0) return;
|
||||
var capturedGroup = pendingProcessFeedGroup.ToList();
|
||||
var ts = pendingGroupTimestamp;
|
||||
var groupKey = $"eg_{ts.Ticks}_{eventIndex++}";
|
||||
timeline.Add((groupKey, ts, 1, () => AddCollapsedProcessFeedGroup(capturedGroup)));
|
||||
pendingProcessFeedGroup.Clear();
|
||||
}
|
||||
|
||||
foreach (var executionEvent in visibleEvents)
|
||||
{
|
||||
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
|
||||
@@ -164,7 +194,6 @@ public partial class ChatWindow
|
||||
consecutiveToolCallCount++;
|
||||
continue; // 연속 중복 스킵
|
||||
}
|
||||
// 이전 연속 카운트가 있었으면 이전 pill에 반영됨
|
||||
prevToolCallName = restoredEvent.ToolName;
|
||||
consecutiveToolCallCount = 1;
|
||||
}
|
||||
@@ -174,9 +203,22 @@ public partial class ChatWindow
|
||||
consecutiveToolCallCount = 0;
|
||||
}
|
||||
|
||||
// 과거 대화(비-스트리밍)에서 process feed 이벤트를 그룹으로 묶기
|
||||
if (!_isStreaming && IsProcessFeedEvent(restoredEvent))
|
||||
{
|
||||
pendingProcessFeedGroup.Add(restoredEvent);
|
||||
pendingGroupTimestamp = executionEvent.Timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 비-process feed 이벤트가 오면 이전 그룹 플러시
|
||||
FlushProcessFeedGroup();
|
||||
|
||||
var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
|
||||
timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
|
||||
}
|
||||
// 마지막 그룹 플러시
|
||||
FlushProcessFeedGroup();
|
||||
|
||||
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
|
||||
if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0)
|
||||
@@ -187,9 +229,10 @@ public partial class ChatWindow
|
||||
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
|
||||
}
|
||||
|
||||
var liveProgressHint = GetLiveAgentProgressHint();
|
||||
if (liveProgressHint != null)
|
||||
timeline.Add(("_live_hint", liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
|
||||
// 라이브 프로그레스 힌트는 트랜스크립트에 표시하지 않음
|
||||
// 입력창 위의 PulseDotBar로만 상태를 표시 (Claude Desktop 스타일)
|
||||
// 이전에는 "처리 중..." / "작업을 준비하는 중입니다..." 등이
|
||||
// 트랜스크립트에 불필요하게 표시되어 사용자 경험을 저해함
|
||||
|
||||
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피
|
||||
var needsSort = false;
|
||||
@@ -237,7 +280,7 @@ public partial class ChatWindow
|
||||
new TextBlock
|
||||
{
|
||||
Text = "\uE70D",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 8,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
@@ -312,7 +355,7 @@ public partial class ChatWindow
|
||||
{
|
||||
"session_memory_compaction" => "세션 메모리 압축",
|
||||
"collapsed_boundary" => "압축 경계 병합",
|
||||
_ => "Microcompact 경계",
|
||||
_ => "컨텍스트 정리",
|
||||
};
|
||||
|
||||
var wrapper = new Border
|
||||
@@ -332,7 +375,7 @@ public partial class ChatWindow
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = accentBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -348,34 +391,20 @@ public partial class ChatWindow
|
||||
});
|
||||
stack.Children.Add(header);
|
||||
|
||||
var lines = (message.Content ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim()).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
|
||||
foreach (var line in lines)
|
||||
var summary = message.MetaKind switch
|
||||
{
|
||||
var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal);
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = isHeaderLine ? line.Trim('[', ']') : line,
|
||||
FontSize = 10.5,
|
||||
FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal,
|
||||
Foreground = isHeaderLine ? primaryText : secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2),
|
||||
});
|
||||
}
|
||||
"session_memory_compaction" => "이전 요약과 실행 경계를 하나의 세션 메모로 정리했습니다.",
|
||||
"collapsed_boundary" => "이전 compact 경계를 합쳐 transcript를 더 가볍게 유지했습니다.",
|
||||
_ => "오래된 실행/도구 결과를 줄여 다음 요청 컨텍스트를 가볍게 만들었습니다.",
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.MetaRunId))
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"run {message.MetaRunId}",
|
||||
FontSize = 9.5,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.7,
|
||||
Margin = new Thickness(0, 6, 0, 0),
|
||||
});
|
||||
}
|
||||
Text = summary,
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
|
||||
wrapper.Child = stack;
|
||||
return wrapper;
|
||||
|
||||
Reference in New Issue
Block a user