[Phase 18] 멀티에이전트 인프라 완성 + 리플레이 탭 통합

Phase 17-D3 수정:
- SkillManagerTool.ListSkills(): UserInvocable=false 내부 스킬 필터링 추가
  (SkillServiceExtensions.IsUserInvocable() 활용)

Phase 18-A 연결 완성:
- ToolRegistry: BackgroundAgentService 프로퍼티 노출 (AgentCompleted 구독용)
- ToolRegistry: WorktreeManager 인스턴스 생성 → DelegateAgentTool에 주입 (18-A2 완성)
- ChatWindow.xaml.cs: _toolRegistry.BackgroundAgentService.AgentCompleted 구독
- ChatWindow.AgentSupport.cs: OnBackgroundAgentCompleted() — 완료/실패 트레이 알림

Phase 18-B: 리플레이/디버깅 UI 통합:
- WorkflowAnalyzerWindow.xaml: "리플레이" 탭 버튼 추가 ()
- WorkflowAnalyzerWindow.xaml: 리플레이 패널 — 세션 목록, 재생 컨트롤, 속도 슬라이더, 진행 바, 이벤트 스트림
- WorkflowAnalyzerWindow.xaml.cs: TabReplay_Click, UpdateTabVisuals 3탭 지원
- WorkflowAnalyzerWindow.xaml.cs: LoadReplaySessions, BtnReplayStart/Stop, BuildReplayEventRow
- ReplayTimelineViewModel 통합 — StartReplayAsync/StopReplay, ReplayEventReceived 이벤트
- AGENT_ROADMAP.md: 18-A2/A3/A4/B1  완료 표시 갱신

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 01:30:29 +09:00
parent 0ef37554bc
commit 84e5cd9485
7 changed files with 294 additions and 16 deletions

View File

@@ -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 변수 치환 | 중간 | — |

View File

@@ -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 파일을 추가하세요.");

View File

@@ -11,6 +11,9 @@ public class ToolRegistry : IDisposable
/// <summary>등록된 모든 도구 목록.</summary>
public IReadOnlyCollection<IAgentTool> All => _tools.Values;
/// <summary>Phase 18-A: 백그라운드 에이전트 서비스 (AgentCompleted 이벤트 구독용).</summary>
public BackgroundAgentService? BackgroundAgentService { get; private set; }
/// <summary>도구를 이름으로 찾습니다.</summary>
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;
}

View File

@@ -407,6 +407,32 @@ public partial class ChatWindow
}
}
// ─── Phase 18-A: 백그라운드 에이전트 완료 알림 ──────────────────────────
/// <summary>
/// 위임 에이전트(delegate_agent)가 백그라운드에서 완료되면 트레이 알림을 표시합니다.
/// </summary>
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)]}");
}
});
}
/// <summary>에이전트 루프 동안 누적 토큰 (하단 바 표시용)</summary>
private int _agentCumulativeInputTokens;
private int _agentCumulativeOutputTokens;

View File

@@ -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";

View File

@@ -114,6 +114,16 @@
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Phase 18-B: 리플레이 탭 -->
<Border x:Name="TabReplay" CornerRadius="6" Padding="12,6" Margin="0,0,4,0"
Cursor="Hand" MouseLeftButtonUp="TabReplay_Click">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TabReplayIcon" Text="&#xE768;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="TabReplayText" Text="리플레이" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
@@ -153,6 +163,55 @@
</StackPanel>
</ScrollViewer>
<!-- ═══ 콘텐츠: 리플레이 (Phase 18-B) ═══ -->
<ScrollViewer Grid.Row="3" x:Name="ReplayScroller" VerticalScrollBarVisibility="Auto"
Margin="12,0,12,0" Padding="0,0,4,0" Visibility="Collapsed">
<StackPanel x:Name="ReplayPanel" Margin="0,4,0,8">
<!-- 세션 선택 -->
<TextBlock Text="📂 저장된 세션" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="4,0,0,6"/>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8"
Padding="8,6" Margin="0,0,0,8" MaxHeight="120">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="ReplaySessionList"/>
</ScrollViewer>
</Border>
<!-- 재생 컨트롤 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border x:Name="BtnReplayStart" CornerRadius="6" Padding="12,6" Cursor="Hand"
Background="{DynamicResource AccentColor}" Margin="0,0,6,0"
MouseLeftButtonUp="BtnReplayStart_Click">
<TextBlock Text="▶ 재생" FontSize="12" Foreground="White" FontWeight="SemiBold"/>
</Border>
<Border x:Name="BtnReplayStop" CornerRadius="6" Padding="12,6" Cursor="Hand"
Background="{DynamicResource ItemBackground}" Margin="0,0,6,0"
MouseLeftButtonUp="BtnReplayStop_Click">
<TextBlock Text="■ 중단" FontSize="12" Foreground="{DynamicResource PrimaryText}" FontWeight="SemiBold"/>
</Border>
<TextBlock Text="속도 (ms):" FontSize="11" Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="8,0,6,0"/>
<Slider x:Name="ReplaySpeedSlider" Minimum="50" Maximum="1000" Value="200"
Width="80" VerticalAlignment="Center" ValueChanged="ReplaySpeedSlider_Changed"/>
<TextBlock x:Name="ReplaySpeedLabel" Text="200ms" FontSize="11"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center" Margin="4,0,0,0"/>
</StackPanel>
<!-- 진행 표시줄 -->
<ProgressBar x:Name="ReplayProgress" Height="4"
Foreground="{DynamicResource AccentColor}"
Background="{DynamicResource ItemBackground}"
Margin="0,0,0,8" Minimum="0" Maximum="100" Value="0"/>
<!-- 재생 이벤트 스트림 -->
<TextBlock Text="▶ 이벤트 스트림" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="4,0,0,6"/>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8"
Padding="8,6" Margin="0,0,0,4" MinHeight="60">
<ScrollViewer x:Name="ReplayEventScroll" VerticalScrollBarVisibility="Auto" MaxHeight="200">
<StackPanel x:Name="ReplayEventPanel"/>
</ScrollViewer>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ═══ 상세 패널 ═══ -->
<Border Grid.Row="4" x:Name="DetailPanel" Visibility="Collapsed"
Background="{DynamicResource ItemBackground}" CornerRadius="8"

View File

@@ -1,3 +1,4 @@
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
@@ -6,6 +7,7 @@ using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Shapes;
using AxCopilot.Services.Agent;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
@@ -35,6 +37,9 @@ public partial class WorkflowAnalyzerWindow : Window
private readonly List<(int Iteration, int Input, int Output)> _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;
/// <summary>저장된 세션 목록을 리플레이 패널에 로드합니다.</summary>
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<Border>())
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<double> e)
{
_replayVm.ReplaySpeedMs = (int)e.NewValue;
if (ReplaySpeedLabel != null)
ReplaySpeedLabel.Text = $"{(int)e.NewValue}ms";
}
/// <summary>리플레이 이벤트 하나를 작은 행 UI로 빌드합니다.</summary>
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),
};
}
}