From 90d59433278a612a567cc62dbadd7a1b656b3a2b Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 23:07:59 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=2017-A]=20Reflexion=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=EC=84=B1=EC=B0=B0=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReflexionEvaluatorService 신규 구현 (ReflexionService.cs): - LLM 기반 자기평가: 완성도 점수(0~1), 강점/약점/교훈 추출 - $$raw string 평가 프롬프트로 JSON 포맷 안전하게 삽입 - JSON 블록 추출 + 역직렬화, LLM 실패 시 규칙 기반 폴백 엔트리 - ReflexionRepository.BuildContextPromptAsync() maxEntries 파라미터 추가 AgentLoopService.Reflexion.cs (신규, 82줄): - InjectReflexionContextAsync(): 세션 시작 전 과거 교훈→시스템 메시지 주입 - FireAndForgetReflexionEval(): 세션 완료 후 Task.Run 비동기 자기평가 저장 - 지연 초기화(_reflexionRepo, _reflexionEval): 사용 시점에 생성 AgentLoopService.cs 통합 포인트 2개 추가: - RunAsync() 루프 시작 전: await InjectReflexionContextAsync() - finally 블록 통계 섹션: FireAndForgetReflexionEval() 호출 AgentSettingsPanel — 자기성찰 메모리 섹션 추가: - 활성화 토글(ChkReflexionEnabled) - 성공 세션만 평가 토글(ChkReflexionSuccessOnly) - 최대 참고 교훈 수 슬라이더(1~20, 기본값 5) - LoadFromSettings() 초기화 + 3개 이벤트 핸들러 빌드: 경고 0, 오류 0 --- docs/NEXT_ROADMAP.md | 26 ++- .../Agent/AgentLoopService.Reflexion.cs | 87 +++++++++ .../Services/Agent/AgentLoopService.cs | 10 ++ .../Services/Agent/ReflexionService.cs | 169 +++++++++++++++++- .../Views/Controls/AgentSettingsPanel.xaml | 51 ++++++ .../Views/Controls/AgentSettingsPanel.xaml.cs | 41 +++++ 6 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/AgentLoopService.Reflexion.cs diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index bea076a..683e2e5 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4954,5 +4954,29 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E 구현 완료) +## Phase 17-A — Reflexion 강화 (v1.8.0) ✅ 완료 + +> **목표**: 성공·실패 모두 구조화된 자기평가 저장 → 동일 작업 유형 재실행 시 자동 참고 + +### 변경 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `ReflexionService.cs` | `ReflexionEvaluatorService` 신규 추가: LLM 기반 자기평가(점수·강점·약점·교훈 추출), 규칙 기반 폴백 엔트리. `ReflexionRepository.BuildContextPromptAsync()` maxEntries 파라미터 추가. | +| `AgentLoopService.Reflexion.cs` (신규, 82줄) | `InjectReflexionContextAsync()` — 세션 시작 시 과거 교훈을 시스템 메시지에 주입. `FireAndForgetReflexionEval()` — 세션 완료 후 비동기 자기평가 저장. | +| `AgentLoopService.cs` | `RunAsync()` 루프 시작 전: `InjectReflexionContextAsync()` 호출. `finally` 블록: `FireAndForgetReflexionEval()` 호출. | +| `AgentSettingsPanel.xaml` | "자기성찰 메모리" 섹션 추가: 활성화 토글, 성공 시만 평가 토글, 최대 참고 교훈 수 슬라이더. | +| `AgentSettingsPanel.xaml.cs` | `LoadFromSettings()`: Reflexion 설정 초기화. `ChkReflexion_Changed()`, `ChkReflexionSuccessOnly_Changed()`, `SliderReflexionMaxEntries_ValueChanged()` 핸들러 추가. | + +### 구현 세부사항 + +- **ReflexionEvaluatorService**: `$$"""..."""` raw string으로 평가 프롬프트 생성, JSON 블록 추출 후 역직렬화 +- **작업 유형 분류**: `TaskTypeClassifier.Classify()` — 8개 카테고리 (code_generation, file_refactor, document, analysis, search, test, git, debug, general) +- **저장 위치**: `%APPDATA%\AxCopilot\reflexion\.jsonl` (JSONL 형식, 최신 N개 역순 조회) +- **비동기 처리**: fire-and-forget — 루프 취소(`CancellationToken`) 영향 없음 +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A 구현 완료) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Reflexion.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Reflexion.cs new file mode 100644 index 0000000..9aac1fc --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Reflexion.cs @@ -0,0 +1,87 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-A: AgentLoopService — Reflexion(자기성찰 메모리) 통합. +/// 세션 시작 시 과거 교훈을 시스템 메시지에 주입하고, +/// 세션 완료 후 LLM 자기평가를 비동기로 저장합니다. +/// +public partial class AgentLoopService +{ + // ── 지연 초기화 (첫 사용 시 생성) ──────────────────────────────────── + private ReflexionRepository? _reflexionRepo; + private ReflexionEvaluatorService? _reflexionEval; + + private ReflexionRepository ReflexionRepo => + _reflexionRepo ??= new ReflexionRepository(); + + private ReflexionEvaluatorService ReflexionEval => + _reflexionEval ??= new ReflexionEvaluatorService(_llm, ReflexionRepo); + + /// + /// 세션 시작 시 과거 유사 작업 교훈을 시스템 메시지 끝에 주입합니다. + /// Reflexion이 비활성화되었거나 교훈이 없으면 아무것도 하지 않습니다. + /// + internal async Task InjectReflexionContextAsync(List messages, string userQuery) + { + var reflexion = _settings.Settings.Llm.Reflexion; + if (reflexion?.Enabled != true) return; + if (string.IsNullOrWhiteSpace(userQuery)) return; + + try + { + var taskType = TaskTypeClassifier.Classify(userQuery); + var maxEntries = reflexion.MaxContextEntries > 0 ? reflexion.MaxContextEntries : 5; + var context = await ReflexionRepo.BuildContextPromptAsync(taskType, maxEntries); + if (string.IsNullOrEmpty(context)) return; + + // 시스템 메시지 찾기 (첫 번째 system 역할 메시지) + var sysMsg = messages.FirstOrDefault(m => m.Role == "system"); + if (sysMsg != null) + sysMsg.Content += "\n" + context; + // 시스템 메시지가 없으면 리스트 앞에 추가 + else + messages.Insert(0, new ChatMessage { Role = "system", Content = context }); + } + catch (Exception) + { + // 교훈 주입 실패는 루프 진행에 영향 없음 (무시) + } + } + + /// + /// 세션 완료 후 자기평가를 비동기(fire-and-forget)로 실행·저장합니다. + /// 실패 시 루프에 영향 없음. + /// + internal void FireAndForgetReflexionEval( + string userQuery, + string agentResult, + string sessionId, + bool isSuccess, + int toolCallCount, + int iterationCount) + { + var reflexion = _settings.Settings.Llm.Reflexion; + if (reflexion?.Enabled != true) return; + if (!isSuccess && reflexion.EvaluateOnSuccess) return; // 성공 시만 평가 설정일 때 실패면 스킵 + if (string.IsNullOrWhiteSpace(userQuery)) return; + if (toolCallCount == 0) return; // 도구 사용 없는 단순 대화는 평가 불필요 + + // CancellationToken 없이 fire-and-forget — 루프 취소에 영향받지 않음 + _ = Task.Run(async () => + { + try + { + await ReflexionEval.EvaluateAndSaveAsync( + userQuery, agentResult, sessionId, + isSuccess, toolCallCount, iterationCount, + CancellationToken.None); + } + catch (Exception) + { + // 평가 저장 실패 — UI 방해 없이 조용히 무시 + } + }); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 4b39b47..d27dd08 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -196,6 +196,9 @@ public partial class AgentLoopService var context = BuildContext(activeTabSnapshot); + // Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입 + await InjectReflexionContextAsync(messages, userQuery); + try { // ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ── @@ -820,6 +823,13 @@ public partial class AgentLoopService $"소요 {durationSec:F1}초 | 사용 도구: {toolList}"; EmitEvent(AgentEventType.StepDone, "total_stats", summary); } + + // Phase 17-A: Reflexion — 세션 완료 후 자기평가 비동기 저장 + var isSessionSuccess = statsSuccessCount > 0 && statsFailCount == 0; + var lastResult = messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? ""; + FireAndForgetReflexionEval( + userQuery, lastResult, _sessionId ?? "", + isSessionSuccess, totalToolCalls, iteration); } } } diff --git a/src/AxCopilot/Services/Agent/ReflexionService.cs b/src/AxCopilot/Services/Agent/ReflexionService.cs index 9e868db..5b36fb7 100644 --- a/src/AxCopilot/Services/Agent/ReflexionService.cs +++ b/src/AxCopilot/Services/Agent/ReflexionService.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using AxCopilot.Models; namespace AxCopilot.Services.Agent; @@ -81,13 +82,13 @@ public class ReflexionRepository } /// 동일 작업 유형의 과거 교훈을 시스템 프롬프트 주입용 텍스트로 빌드. - public async Task BuildContextPromptAsync(string taskType) + public async Task BuildContextPromptAsync(string taskType, int maxEntries = 5) { - var entries = await GetByTaskTypeAsync(taskType, 5); + var entries = await GetByTaskTypeAsync(taskType, maxEntries); if (entries.Count == 0) return string.Empty; var sb = new StringBuilder(); - sb.AppendLine($"## 이전 유사 작업({taskType})에서 배운 점:"); + sb.AppendLine($"\n## 이전 유사 작업({taskType})에서 배운 점:"); foreach (var e in entries) { if (e.Lessons.Length > 0) @@ -136,3 +137,165 @@ public static class TaskTypeClassifier return best; } } + +/// +/// Phase 17-A: LLM 기반 자기평가 서비스. +/// 세션 완료 후 LLM을 호출해 완성도·강점·약점·교훈을 추출하고 ReflexionEntry를 생성합니다. +/// +public class ReflexionEvaluatorService +{ + private readonly LlmService _llm; + private readonly ReflexionRepository _repo; + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + PropertyNameCaseInsensitive = true, + }; + + public ReflexionEvaluatorService(LlmService llm, ReflexionRepository repo) + { + _llm = llm; + _repo = repo; + } + + /// + /// 세션을 평가하고 ReflexionEntry를 저장합니다. + /// 내부 LLM 호출 실패 시 규칙 기반 폴백 엔트리를 저장합니다. + /// + public async Task EvaluateAndSaveAsync( + string userQuery, + string agentResult, + string sessionId, + bool isSuccess, + int toolCallCount, + int iterationCount, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(userQuery)) return; + + var taskType = TaskTypeClassifier.Classify(userQuery); + + try + { + var entry = await EvaluateWithLlmAsync( + userQuery, agentResult, taskType, sessionId, isSuccess, toolCallCount, iterationCount, ct); + await _repo.SaveAsync(entry); + } + catch (Exception) + { + // LLM 평가 실패 → 규칙 기반 폴백 엔트리 + var fallback = BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount); + try { await _repo.SaveAsync(fallback); } catch (Exception) { /* 저장 실패도 무시 */ } + } + } + + private async Task EvaluateWithLlmAsync( + string userQuery, + string agentResult, + string taskType, + string sessionId, + bool isSuccess, + int toolCallCount, + int iterationCount, + CancellationToken ct) + { + var resultPreview = agentResult.Length > 500 ? agentResult[..500] + "…" : agentResult; + var evalPrompt = $$""" + 에이전트가 다음 작업을 수행했습니다. 수행 결과를 평가하고 JSON으로 응답하세요. + + ## 사용자 요청 + {{userQuery}} + + ## 에이전트 결과 요약 + {{resultPreview}} + + ## 수행 통계 + - 성공 여부: {{(isSuccess ? "성공" : "실패 또는 중단")}} + - 도구 호출 횟수: {{toolCallCount}} + - 반복 횟수: {{iterationCount}} + + ## 응답 형식 (JSON만 출력, 다른 텍스트 없이) + { + "score": 0.0~1.0, + "strengths": ["잘된 점1", "잘된 점2"], + "weaknesses": ["부족한 점1", "부족한 점2"], + "lessons": ["다음에 적용할 교훈1", "다음에 적용할 교훈2"], + "summary": "한 줄 요약" + } + """; + + var messages = new List + { + new() { Role = "user", Content = evalPrompt } + }; + + var response = await _llm.SendAsync(messages, ct); + + // JSON 블록 추출 + var json = ExtractJson(response); + if (string.IsNullOrEmpty(json)) + return BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount); + + var parsed = JsonSerializer.Deserialize(json, _jsonOpts); + if (parsed == null) + return BuildFallbackEntry(userQuery, taskType, sessionId, isSuccess, iterationCount); + + return new ReflexionEntry + { + TaskType = taskType, + Summary = parsed.Summary ?? userQuery[..Math.Min(80, userQuery.Length)], + IsSuccess = isSuccess, + CompletionScore = Math.Clamp(parsed.Score, 0f, 1f), + Strengths = parsed.Strengths ?? Array.Empty(), + Weaknesses = parsed.Weaknesses ?? Array.Empty(), + Lessons = parsed.Lessons ?? Array.Empty(), + CreatedAt = DateTime.UtcNow, + SessionId = sessionId, + }; + } + + private static ReflexionEntry BuildFallbackEntry( + string userQuery, string taskType, string sessionId, bool isSuccess, int iterationCount) + { + return new ReflexionEntry + { + TaskType = taskType, + Summary = userQuery.Length > 80 ? userQuery[..80] + "…" : userQuery, + IsSuccess = isSuccess, + CompletionScore = isSuccess ? 0.7f : 0.3f, + Strengths = isSuccess ? new[] { "작업 완료" } : Array.Empty(), + Weaknesses = !isSuccess ? new[] { $"반복 {iterationCount}회 후 완료 못함" } : Array.Empty(), + Lessons = Array.Empty(), + CreatedAt = DateTime.UtcNow, + SessionId = sessionId, + }; + } + + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start < 0 || end <= start) return string.Empty; + return text[start..(end + 1)]; + } + + // JSON 응답 역직렬화용 내부 DTO + private class EvalResponse + { + [JsonPropertyName("score")] + public float Score { get; set; } + + [JsonPropertyName("strengths")] + public string[]? Strengths { get; set; } + + [JsonPropertyName("weaknesses")] + public string[]? Weaknesses { get; set; } + + [JsonPropertyName("lessons")] + public string[]? Lessons { get; set; } + + [JsonPropertyName("summary")] + public string? Summary { get; set; } + } +} diff --git a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml index 240f836..e2dfaa9 100644 --- a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml +++ b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml @@ -195,6 +195,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml.cs b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml.cs index 6dee029..a476fe9 100644 --- a/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml.cs +++ b/src/AxCopilot/Views/Controls/AgentSettingsPanel.xaml.cs @@ -66,6 +66,13 @@ public partial class AgentSettingsPanel : UserControl SliderAutoCompact.Value = llm.AutoCompactThreshold > 0 ? llm.AutoCompactThreshold : 80; TxtAutoCompact.Text = ((int)SliderAutoCompact.Value).ToString(); + // Phase 17-A: Reflexion 설정 로드 + var reflexion = llm.Reflexion; + ChkReflexionEnabled.IsChecked = reflexion?.Enabled ?? true; + ChkReflexionSuccessOnly.IsChecked = reflexion?.EvaluateOnSuccess ?? true; + SliderReflexionMaxEntries.Value = reflexion?.MaxContextEntries > 0 ? reflexion.MaxContextEntries : 5; + TxtReflexionMaxEntries.Text = ((int)SliderReflexionMaxEntries.Value).ToString(); + ChkDevMode.IsChecked = llm.DevMode; // 탭별 설정 표시 @@ -226,6 +233,40 @@ public partial class AgentSettingsPanel : UserControl SaveSetting(s => s.Llm.DevMode = ChkDevMode.IsChecked == true); } + // ── Phase 17-A: Reflexion 핸들러 ────────────────────────────────────── + + private void ChkReflexion_Changed(object sender, RoutedEventArgs e) + { + if (_isLoading) return; + SaveSetting(s => + { + s.Llm.Reflexion ??= new Models.ReflexionConfig(); + s.Llm.Reflexion.Enabled = ChkReflexionEnabled.IsChecked == true; + }); + } + + private void ChkReflexionSuccessOnly_Changed(object sender, RoutedEventArgs e) + { + if (_isLoading) return; + SaveSetting(s => + { + s.Llm.Reflexion ??= new Models.ReflexionConfig(); + s.Llm.Reflexion.EvaluateOnSuccess = ChkReflexionSuccessOnly.IsChecked == true; + }); + } + + private void SliderReflexionMaxEntries_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isLoading || TxtReflexionMaxEntries == null) return; + var val = (int)SliderReflexionMaxEntries.Value; + TxtReflexionMaxEntries.Text = val.ToString(); + SaveSetting(s => + { + s.Llm.Reflexion ??= new Models.ReflexionConfig(); + s.Llm.Reflexion.MaxContextEntries = val; + }); + } + private void ToolToggle_Changed(object sender, RoutedEventArgs e) { if (_isLoading || sender is not CheckBox chk || chk.Tag is not string toolName) return;