From ef58e93e38c232411f4769966a01f81334dd5458 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 12 Apr 2026 21:53:05 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EB=B3=84=20time-based=20comp?= =?UTF-8?q?act=20=EA=B8=B0=EC=A4=80=EA=B3=BC=20compact=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EB=85=B8=EC=B6=9C=EC=9D=84=20=EA=B2=BD=EB=9F=89?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 4 + docs/DEVELOPMENT.md | 14 +++ .../Services/Agent/ContextCondenser.cs | 37 ++++++-- .../Views/ChatWindow.TimelinePresentation.cs | 93 ++++++++++++------- 4 files changed, 109 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6e31603..4d60f01 100644 --- a/README.md +++ b/README.md @@ -1643,3 +1643,7 @@ MIT License - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 마지막 assistant 이후 20분 이상 경과한 경우, 가장 최근 `tool_result` 1개만 남기고 나머지는 작은 cleared marker로 바꾸도록 처리합니다. - [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 cleared `tool_result` JSON을 만드는 helper를 추가해, 기존 `tool_use_id`/`tool_name`은 유지하면서 content만 가볍게 비우도록 보강했습니다. - [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)는 compact 직후 첫 턴에서 추가 운영성 thinking 문구를 다시 띄우지 않고 내부 상태만 갱신하도록 바꿔, compact 후 흐름이 더 조용하게 이어지도록 정리했습니다. +- 업데이트: 2026-04-12 22:19 (KST) + - `time-based` tool result 정리 기준을 모델/서비스별로 세분화해, Claude는 더 보수적으로, Qwen/vLLM 계열은 더 빠르게 오래된 결과를 걷어내도록 조정했습니다. + - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `service:model` 조합에 따라 `gapThresholdMinutes`와 `keepRecent`를 다르게 계산하도록 바뀌었습니다. + - [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 긴 상세 줄 목록 대신 한 줄 요약 카드로 단순화해, transcript에서 운영성 카드 밀도를 더 낮췄습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6502853..fd190d6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -631,3 +631,17 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - 오래된 세션을 다시 이어갈 때 과거 `tool_result` 때문에 첫 요청이 불필요하게 비대해지는 현상이 줄어듭니다. - compact 직후 transcript에 운영 문구가 한 번 더 끼어드는 노이즈가 줄어듭니다. +## 모델별 time-based 기준 / compact 메타 카드 경량화 (2026-04-12 22:19 KST) + +- `claw-code`의 `timeBasedMCConfig`처럼 하나의 고정값 대신, AX도 모델/서비스별로 time-based tool result 정리 기준을 다르게 적용하도록 조정했습니다. +- `src/AxCopilot/Services/Agent/ContextCondenser.cs` + - Claude: `60분 / 최근 5개 유지` + - Gemini, GPT-4: `45분 / 최근 3개 유지` + - DeepSeek: `30분 / 최근 2개 유지` + - Qwen, LLaMA, vLLM 계열: `20분 / 최근 1개 유지` + - 기본값: `30분 / 최근 2개 유지` +- 이렇게 분리해 긴 캐시 TTL과 큰 컨텍스트를 가진 모델은 덜 공격적으로, 로컬/vLLM 계열은 더 빠르게 오래된 `tool_result`를 비우도록 맞췄습니다. +- `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs` + - compact 메타 카드를 긴 line-by-line 상세 표시 대신 제목 + 짧은 한 줄 설명으로 단순화했습니다. + - `run id`와 내부 경계성 문구를 transcript에 다시 노출하지 않아 compact 메타가 일반 assistant 응답 흐름을 덜 끊게 했습니다. + diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 5645167..3ebaf8a 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -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 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 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) diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index 3f8c37d..9c3a007 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -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(); @@ -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(); + 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;