모델별 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:
2026-04-12 21:53:05 +09:00
parent bdd4444deb
commit ef58e93e38
4 changed files with 109 additions and 39 deletions

View File

@@ -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에서 운영성 카드 밀도를 더 낮췄습니다.

View File

@@ -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 응답 흐름을 덜 끊게 했습니다.

View File

@@ -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)

View File

@@ -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);
"session_memory_compaction" => "이전 요약과 실행 경계를 하나의 세션 메모로 정리했습니다.",
"collapsed_boundary" => "이전 compact 경계를 합쳐 transcript를 더 가볍게 유지했습니다.",
_ => "오래된 실행/도구 결과를 줄여 다음 요청 컨텍스트를 가볍게 만들었습니다.",
};
stack.Children.Add(new TextBlock
{
Text = isHeaderLine ? line.Trim('[', ']') : line,
Text = summary,
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),
});
}
if (!string.IsNullOrWhiteSpace(message.MetaRunId))
{
stack.Children.Add(new TextBlock
{
Text = $"run {message.MetaRunId}",
FontSize = 9.5,
Foreground = secondaryText,
Opacity = 0.7,
Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
wrapper.Child = stack;
return wrapper;