diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 909dd86..7fe0940 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -284,15 +284,15 @@ | # | 기능 | 설명 | 우선순위 | 갭 | |---|------|------|----------|----| | 18-A1 | **코디네이터 에이전트 모드** | 계획·라우팅 전담, 구현은 서브에이전트 위임 | 최고 | G1 | -| 18-A2 | **Worktree 격리 서브에이전트** | 서브에이전트별 독립 git working copy. 승인 후 병합 | 최고 | — | -| 18-A3 | **에이전트 팀 위임 (delegate 도구)** | 특화 에이전트(코드·문서·보안·리서치) 독립 루프 실행 | 최고 | G1 | -| 18-A4 | **백그라운드 에이전트 + 타입별 메모리** | 비동기 실행 + 트레이 알림. agent-memory/타입/MEMORY.md | 높음 | — | +| 18-A2 ✅ | **Worktree 격리 서브에이전트** | WorktreeManager 구현 완료 + DelegateAgentTool에 주입 (isolation:"worktree" 파라미터 지원) | 최고 | — | +| 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 | +| 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — | ### Group B — 디버깅 + 생태계 | # | 기능 | 설명 | 우선순위 | 갭 | |---|------|------|----------|----| -| 18-B1 | **에이전트 리플레이/디버깅** | Event-Sourced 로그. 과거 세션 재생·분기 재실행 | 높음 | G8 | +| 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 | | 18-C1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 기반 인앱 갤러리. zip 설치 | 높음 | — | | 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — | | 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — | diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs index 11e9f41..da342ed 100644 --- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs +++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text; using System.Text.Json; @@ -70,7 +71,11 @@ public class SkillManagerTool : IAgentTool private static ToolResult ListSkills() { - var skills = SkillService.Skills; + // Phase 17-D3: UserInvocable=false 스킬은 내부 전용이므로 목록에서 제외 + var skills = SkillService.Skills + .Where(s => s.IsUserInvocable()) + .ToList(); + if (skills.Count == 0) return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요."); diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs index 05cfc2e..5632354 100644 --- a/src/AxCopilot/Services/Agent/ToolRegistry.cs +++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs @@ -11,6 +11,9 @@ public class ToolRegistry : IDisposable /// 등록된 모든 도구 목록. public IReadOnlyCollection All => _tools.Values; + /// Phase 18-A: 백그라운드 에이전트 서비스 (AgentCompleted 이벤트 구독용). + public BackgroundAgentService? BackgroundAgentService { get; private set; } + /// 도구를 이름으로 찾습니다. public IAgentTool? Get(string name) => _tools.TryGetValue(name, out var tool) ? tool : null; @@ -161,7 +164,9 @@ public class ToolRegistry : IDisposable // Phase 18-A: 위임 에이전트 (멀티에이전트 오케스트레이션) var memoryRepo = new AgentTypeMemoryRepository(); var bgService = new BackgroundAgentService(memoryRepo); - registry.Register(new DelegateAgentTool(bgService, memoryRepo)); + var worktreeMgr = new WorktreeManager(); // Phase 18-A2: worktree 격리 + registry.BackgroundAgentService = bgService; // ChatWindow에서 AgentCompleted 구독 + registry.Register(new DelegateAgentTool(bgService, memoryRepo, worktreeMgr)); return registry; } diff --git a/src/AxCopilot/Views/ChatWindow.AgentSupport.cs b/src/AxCopilot/Views/ChatWindow.AgentSupport.cs index a2a9375..f0602c1 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentSupport.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentSupport.cs @@ -407,6 +407,32 @@ public partial class ChatWindow } } + // ─── Phase 18-A: 백그라운드 에이전트 완료 알림 ────────────────────────── + + /// + /// 위임 에이전트(delegate_agent)가 백그라운드에서 완료되면 트레이 알림을 표시합니다. + /// + private void OnBackgroundAgentCompleted(object? sender, AgentCompletedEventArgs e) + { + // UI 스레드에서 안전하게 실행 + Dispatcher.BeginInvoke(() => + { + var task = e.Task; + if (e.Error != null) + { + Services.NotificationService.Notify( + $"에이전트 '{task.AgentType}' 실패", + $"ID {task.Id}: {e.Error[..Math.Min(80, e.Error.Length)]}"); + } + else + { + Services.NotificationService.Notify( + $"에이전트 '{task.AgentType}' 완료", + $"ID {task.Id}: {task.Description[..Math.Min(60, task.Description.Length)]}"); + } + }); + } + /// 에이전트 루프 동안 누적 토큰 (하단 바 표시용) private int _agentCumulativeInputTokens; private int _agentCumulativeOutputTokens; diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index c67131c..ad955e9 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -104,6 +104,10 @@ public partial class ChatWindow : Window }, }; + // Phase 18-A: 백그라운드 에이전트 완료 알림 구독 + if (_toolRegistry.BackgroundAgentService != null) + _toolRegistry.BackgroundAgentService.AgentCompleted += OnBackgroundAgentCompleted; + // 설정에서 초기값 로드 (Loaded 전에도 null 방지) _selectedMood = settings.Settings.Llm.DefaultMood ?? "modern"; _folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "active"; diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml index 94efa8a..1439508 100644 --- a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml +++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml @@ -114,6 +114,16 @@ Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/> + + + + + + + @@ -153,6 +163,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _tokenTrend = new(); private long _lastEventMs; // 워터폴 시작 시간 기준 + // Phase 18-B: 리플레이 ViewModel + private readonly ReplayTimelineViewModel _replayVm = new(); + // ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ───────────── [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); @@ -177,6 +182,14 @@ public partial class WorkflowAnalyzerWindow : Window RenderBottleneckCharts(); } + // Phase 18-B: 리플레이 탭 클릭 + private void TabReplay_Click(object sender, MouseButtonEventArgs e) + { + _activeTab = "replay"; + UpdateTabVisuals(); + LoadReplaySessions(); + } + private void UpdateTabVisuals() { var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; @@ -184,18 +197,25 @@ public partial class WorkflowAnalyzerWindow : Window ?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var isTimeline = _activeTab == "timeline"; - TabTimeline.Background = isTimeline ? accentBrush : itemBg; - TabBottleneck.Background = !isTimeline ? accentBrush : itemBg; + var isTimeline = _activeTab == "timeline"; + var isBottleneck = _activeTab == "bottleneck"; + var isReplay = _activeTab == "replay"; - // 활성 탭: 흰색 텍스트 (AccentColor 배경 위 가시성), 비활성: PrimaryText - TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText; - TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText; - TabBottleneckIcon.Foreground = !isTimeline ? Brushes.White : primaryText; - TabBottleneckText.Foreground = !isTimeline ? Brushes.White : primaryText; + TabTimeline.Background = isTimeline ? accentBrush : itemBg; + TabBottleneck.Background = isBottleneck ? accentBrush : itemBg; + TabReplay.Background = isReplay ? accentBrush : itemBg; - TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed; - BottleneckScroller.Visibility = !isTimeline ? Visibility.Visible : Visibility.Collapsed; + // 활성 탭: 흰색 텍스트, 비활성: PrimaryText + TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText; + TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText; + TabBottleneckIcon.Foreground = isBottleneck ? Brushes.White : primaryText; + TabBottleneckText.Foreground = isBottleneck ? Brushes.White : primaryText; + TabReplayIcon.Foreground = isReplay ? Brushes.White : primaryText; + TabReplayText.Foreground = isReplay ? Brushes.White : primaryText; + + TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed; + BottleneckScroller.Visibility = isBottleneck ? Visibility.Visible : Visibility.Collapsed; + ReplayScroller.Visibility = isReplay ? Visibility.Visible : Visibility.Collapsed; DetailPanel.Visibility = isTimeline && DetailPanel.Tag != null ? Visibility.Visible : Visibility.Collapsed; } @@ -271,4 +291,163 @@ public partial class WorkflowAnalyzerWindow : Window } } + // ─── Phase 18-B: 리플레이 탭 ────────────────────────────────────────── + + private ReplaySessionInfo? _selectedReplaySession; + + /// 저장된 세션 목록을 리플레이 패널에 로드합니다. + private void LoadReplaySessions() + { + _replayVm.LoadSessions(); + ReplaySessionList.Children.Clear(); + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.LightGray; + var itemHover = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + if (_replayVm.Sessions.Count == 0) + { + ReplaySessionList.Children.Add(new TextBlock + { + Text = "저장된 세션이 없습니다.", + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(4, 2, 0, 2), + }); + return; + } + + foreach (var session in _replayVm.Sessions) + { + var row = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 1, 0, 1), + Cursor = System.Windows.Input.Cursors.Hand, + Tag = session, + }; + row.Child = new TextBlock + { + Text = $"{session.CreatedAt:MM-dd HH:mm} [{session.SessionId[..Math.Min(8, session.SessionId.Length)]}] {session.FileSizeBytes / 1024}KB", + FontSize = 11, + Foreground = primaryText, + }; + row.MouseEnter += (_, _) => row.Background = itemHover; + row.MouseLeave += (_, _) => row.Background = null; + row.MouseLeftButtonUp += (_, _) => + { + _selectedReplaySession = session; + _replayVm.SelectedSession = session; + // 선택 강조 + foreach (Border child in ReplaySessionList.Children.OfType()) + child.BorderThickness = new Thickness(0); + row.BorderBrush = TryFindResource("AccentColor") as Brush; + row.BorderThickness = new Thickness(1); + }; + ReplaySessionList.Children.Add(row); + } + } + + private void BtnReplayStart_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_replayVm.SelectedSession == null) return; + + ReplayEventPanel.Children.Clear(); + ReplayProgress.Value = 0; + + // ReplayEventReceived 이벤트 구독 (중복 방지) + _replayVm.ReplayEventReceived -= OnReplayEventReceived; + _replayVm.ReplayEventReceived += OnReplayEventReceived; + + _ = _replayVm.StartReplayAsync(); + } + + private void OnReplayEventReceived(ReplayEventItem item) + { + Dispatcher.Invoke(() => + { + var node = BuildReplayEventRow(item); + ReplayEventPanel.Children.Add(node); + ReplayEventScroll.ScrollToEnd(); + + // 진행 표시 + if (_replayVm.ProgressMax > 0) + ReplayProgress.Value = (double)_replayVm.ProgressValue / _replayVm.ProgressMax * 100; + }); + } + + private void BtnReplayStop_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + => _replayVm.StopReplay(); + + private void ReplaySpeedSlider_Changed(object sender, System.Windows.RoutedPropertyChangedEventArgs e) + { + _replayVm.ReplaySpeedMs = (int)e.NewValue; + if (ReplaySpeedLabel != null) + ReplaySpeedLabel.Text = $"{(int)e.NewValue}ms"; + } + + /// 리플레이 이벤트 하나를 작은 행 UI로 빌드합니다. + private Border BuildReplayEventRow(ReplayEventItem item) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var color = item.Record.Type switch + { + AgentEventLogType.ToolRequest => Color.FromRgb(0x60, 0xA5, 0xFA), + AgentEventLogType.ToolResult => Color.FromRgb(0x34, 0xD3, 0x99), + AgentEventLogType.Error => Color.FromRgb(0xF8, 0x71, 0x71), + AgentEventLogType.SessionEnd => Color.FromRgb(0x34, 0xD3, 0x99), + AgentEventLogType.AssistantMessage => Color.FromRgb(0xFB, 0xBF, 0x24), + _ => Color.FromRgb(0x94, 0xA3, 0xB8), + }; + var icon = item.Icon; + var summary = item.Record.Payload ?? item.TypeLabel; + + var grid = new System.Windows.Controls.Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(8, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var iconTb = new TextBlock + { + Text = icon, + FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 10, + Foreground = new SolidColorBrush(color), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconTb, 0); + grid.Children.Add(iconTb); + + var summaryTb = new TextBlock + { + Text = summary.Length > 60 ? summary[..57] + "…" : summary, + FontSize = 10.5, + Foreground = primaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 4, 0), + }; + Grid.SetColumn(summaryTb, 1); + grid.Children.Add(summaryTb); + + var timeTb = new TextBlock + { + Text = item.Record.Timestamp.ToString("HH:mm:ss"), + FontSize = 9, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(timeTb, 2); + grid.Children.Add(timeTb); + + return new Border + { + Child = grid, + Padding = new Thickness(4, 2, 4, 2), + Margin = new Thickness(0, 1, 0, 1), + }; + } + }