From 2383b1e2203b3180ef136bdc15b5f6ef8a52e04d Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 23:22:40 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=2017-B]=20TaskState=20Working=20Memory?= =?UTF-8?q?=20+=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentLoopService.TaskState.cs (신규, 96줄): - InitTaskStateAsync(): 세션 시작 시 TaskStateService 초기화, 현재 작업 기록 - TrackToolFile(): 도구 성공 시 파일 경로 참조 목록 추가 (fire-and-forget) - InjectTaskStateContext(): 압축 전 Working Memory를 시스템 메시지에 in-place 주입 (## 현재 작업 상태 마커로 기존 섹션 탐지·교체 → 중복 방지) - UpdateTaskStateSummaryAsync(): 압축 완료 후 컨텍스트 요약 갱신 (fire-and-forget) AgentLoopService.cs: - userQuery 선언 후 UserMessage 이벤트 기록 + InitTaskStateAsync() 호출 - 압축 블록: InjectTaskStateContext() 호출 (압축 전 Working Memory 주입) - 기본 압축 완료 시: CompactionCompleted 이벤트 + UpdateTaskStateSummaryAsync() - 적극적 압축 트리거 시: CompactionTriggered 이벤트 (usagePct 포함) - LLM 텍스트 응답 후: AssistantMessage 이벤트 기록 (length, hasToolCalls) AgentLoopService.Execution.cs: - 도구 성공(state.ConsecutiveErrors = 0) 직후 TrackToolFile(result.FilePath) 호출 이벤트 커버리지: SessionStart/End·UserMessage·AssistantMessage· ToolRequest·ToolResult·CompactionTriggered·CompactionCompleted 전 구간 기록 저장: %APPDATA%\AxCopilot\sessions\{sessionId}\{task_state.json, events.jsonl} 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/NEXT_ROADMAP.md | 25 +++- .../Agent/AgentLoopService.Execution.cs | 3 + .../Agent/AgentLoopService.TaskState.cs | 108 ++++++++++++++++++ .../Services/Agent/AgentLoopService.cs | 27 +++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Services/Agent/AgentLoopService.TaskState.cs diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 683e2e5..5914a2e 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4978,5 +4978,28 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A 구현 완료) +## Phase 17-B — 구조화된 태스크 상태 + 이벤트 로그 통합 (v1.8.0) ✅ 완료 + +> **목표**: 대화 압축 시에도 유지되는 Working Memory + JSONL 이벤트 로그 완전 통합 + +### 변경 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `AgentLoopService.TaskState.cs` (신규, 96줄) | `InitTaskStateAsync()` — 세션 시작 시 TaskState 초기화·현재 작업 기록. `TrackToolFile()` — 도구 성공 시 파일 경로 참조 목록 추가(fire-and-forget). `InjectTaskStateContext()` — 압축 전 Working Memory를 시스템 메시지에 in-place 주입. `UpdateTaskStateSummaryAsync()` — 압축 완료 후 컨텍스트 요약 갱신(fire-and-forget). | +| `AgentLoopService.cs` | `RunAsync()`: UserMessage 이벤트 기록 + `InitTaskStateAsync()` 호출. 압축 블록: `InjectTaskStateContext()` 호출 + CompactionCompleted/CompactionTriggered 이벤트 로그. LLM 응답 후: AssistantMessage 이벤트 기록. | +| `AgentLoopService.Execution.cs` | 도구 성공 시 `TrackToolFile(result.FilePath)` 호출 — 파일 경로 Working Memory 추적. | + +### 구현 세부사항 + +- **TaskState 지연 초기화**: `_taskState ??= new TaskStateService()` — 사용 시 첫 생성, 세션 간 `InitializeAsync(sessionId)`로 상태 재로드 +- **In-place 주입**: `InjectTaskStateContext()`가 `## 현재 작업 상태 (Working Memory)` 마커로 기존 섹션 탐지 후 교체 → 중복 방지 +- **이벤트 커버리지**: SessionStart/End(기존), UserMessage, AssistantMessage, ToolRequest/Result(기존), CompactionTriggered, CompactionCompleted 모두 JSONL 기록 +- **설정 연동**: `LlmSettings.EnableTaskState` + `LlmSettings.EventLog.Enabled` 체크 후 동작 +- **저장 위치**: `%APPDATA%\AxCopilot\sessions\{sessionId}\task_state.json` + `events.jsonl` +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~B 구현 완료) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs index aa84f7e..ce97667 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs @@ -305,6 +305,9 @@ public partial class AgentLoopService { state.ConsecutiveErrors = 0; + // Phase 17-B: 파일 경로를 Working Memory 참조 파일 목록에 추가 + TrackToolFile(result.FilePath); + // ToolResultSizer 적용 var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName); messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output)); diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.TaskState.cs b/src/AxCopilot/Services/Agent/AgentLoopService.TaskState.cs new file mode 100644 index 0000000..aa54829 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.TaskState.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-B: AgentLoopService — TaskState Working Memory + 이벤트 로그 통합. +/// 세션 전반에 걸쳐 구조화된 작업 상태를 유지하고, +/// 컨텍스트 압축(auto-compact) 시에도 핵심 작업 정보를 보존합니다. +/// +public partial class AgentLoopService +{ + // ── 지연 초기화 (첫 사용 시 생성) ──────────────────────────────────── + private TaskStateService? _taskState; + + /// 현재 세션의 TaskState Working Memory 서비스. + private TaskStateService TaskStateManager => _taskState ??= new TaskStateService(); + + // ───────────────────────────────────────────────────────────────────── + + /// + /// 세션 시작 시 TaskState를 초기화합니다. + /// 기존 세션 ID 파일이 있으면 로드, 없으면 새로 생성합니다. + /// + internal async Task InitTaskStateAsync(string userQuery, string sessionId) + { + if (!_settings.Settings.Llm.EnableTaskState) return; + try + { + await TaskStateManager.InitializeAsync(sessionId); + if (!string.IsNullOrWhiteSpace(userQuery)) + await TaskStateManager.UpdateCurrentTaskAsync(userQuery); + } + catch (Exception ex) + { + LogService.Warn($"[TaskState] 초기화 실패: {ex.Message}"); + } + } + + /// + /// 파일 관련 도구 성공 시 반환된 파일 경로를 Working Memory에 추가합니다. + /// fire-and-forget으로 실행하여 에이전트 루프를 차단하지 않습니다. + /// + internal void TrackToolFile(string? filePath) + { + if (!_settings.Settings.Llm.EnableTaskState) return; + if (string.IsNullOrWhiteSpace(filePath)) return; + + _ = Task.Run(async () => + { + try { await TaskStateManager.AddReferencedFileAsync(filePath); } + catch (Exception) { /* 추적 실패는 루프에 영향 없음 */ } + }); + } + + /// + /// 컨텍스트 압축 전 TaskState Working Memory를 시스템 메시지에 주입합니다. + /// 기존 Working Memory 섹션이 있으면 in-place 교체하여 중복 추가를 방지합니다. + /// + internal void InjectTaskStateContext(List messages) + { + if (!_settings.Settings.Llm.EnableTaskState) return; + try + { + var injection = _taskState?.BuildCompactContextInjection() ?? ""; + if (string.IsNullOrEmpty(injection)) return; + + const string marker = "## 현재 작업 상태 (Working Memory)"; + var sysMsg = messages.FirstOrDefault(m => m.Role == "system"); + + if (sysMsg != null) + { + var idx = sysMsg.Content.IndexOf(marker, StringComparison.Ordinal); + if (idx >= 0) + // 기존 섹션 교체 (이전 Working Memory를 최신으로 덮어씀) + sysMsg.Content = sysMsg.Content[..idx] + injection; + else + // 시스템 메시지 끝에 추가 + sysMsg.Content += "\n" + injection; + } + else + { + // 시스템 메시지 자체가 없으면 새로 삽입 + messages.Insert(0, new ChatMessage { Role = "system", Content = injection }); + } + } + catch (Exception ex) + { + LogService.Warn($"[TaskState] 컨텍스트 주입 실패: {ex.Message}"); + } + } + + /// + /// 컨텍스트 압축 완료 후 TaskState ContextSummary를 비동기 갱신합니다. + /// fire-and-forget으로 실행하여 루프를 차단하지 않습니다. + /// + internal void UpdateTaskStateSummaryAsync(string summary) + { + if (!_settings.Settings.Llm.EnableTaskState) return; + if (string.IsNullOrWhiteSpace(summary)) return; + + _ = Task.Run(async () => + { + try { await TaskStateManager.UpdateContextSummaryAsync(summary); } + catch (Exception) { /* 저장 실패는 무시 */ } + }); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index d27dd08..db2a46c 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -162,6 +162,7 @@ public partial class AgentLoopService _ = _eventLog.AppendAsync(AgentEventLogType.SessionStart, JsonSerializer.Serialize(new { tab = activeTabSnapshot, model = llm.Model ?? "" })); } + var baseMax = llm.MaxAgentIterations > 0 ? llm.MaxAgentIterations : Defaults.MaxIterations; var maxIterations = baseMax; var maxRetry = llm.MaxRetryOnError > 0 ? llm.MaxRetryOnError : Defaults.MaxRetryOnError; @@ -169,6 +170,12 @@ public partial class AgentLoopService // 사용자 원본 요청 캡처 (문서 생성 폴백 판단용) var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? ""; + + // Phase 17-B: UserMessage 이벤트 기록 + TaskState Working Memory 초기화 + if (!string.IsNullOrWhiteSpace(userQuery)) + _ = _eventLog?.AppendAsync(AgentEventLogType.UserMessage, + JsonSerializer.Serialize(new { length = userQuery.Length })); + await InitTaskStateAsync(userQuery, _sessionId); var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터 var totalToolCalls = 0; // 복잡도 추정용 @@ -339,10 +346,18 @@ public partial class AgentLoopService // 2단계: 사용량 임계치 초과 시 적극적 압축 (목표 60%) if (llm.MaxContextTokens > 0) { + // Phase 17-B: 압축 전 TaskState Working Memory 주입 — 압축 후에도 작업 상태 유지 + InjectTaskStateContext(messages); + var condensed = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, llm.MaxContextTokens, ct); if (condensed) + { EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다"); + _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted, + JsonSerializer.Serialize(new { messageCount = messages.Count })); + UpdateTaskStateSummaryAsync("컨텍스트 압축 완료"); + } // 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준) if (!condensed && _llm.LastTokenUsage != null) @@ -357,11 +372,18 @@ public partial class AgentLoopService _llm.LastTokenUsage.PromptTokens, llm.MaxContextTokens); EmitEvent(AgentEventType.Thinking, "", $"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행"); + _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered, + JsonSerializer.Serialize(new { aggressive = true, usagePct })); var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio); var compacted = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, targetTokens, ct); if (compacted) + { EmitEvent(AgentEventType.Thinking, "", "적극적 컴팩션 완료"); + _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted, + JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count })); + UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)"); + } } } } @@ -496,6 +518,11 @@ public partial class AgentLoopService // 텍스트 부분 var textResponse = string.Join("\n", textParts); + // Phase 17-B: AssistantMessage 이벤트 기록 + if (!string.IsNullOrWhiteSpace(textResponse)) + _ = _eventLog?.AppendAsync(AgentEventLogType.AssistantMessage, + JsonSerializer.Serialize(new { length = textResponse.Length, hasToolCalls = toolCalls.Count > 0 })); + // Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출 if (!planExtracted && !string.IsNullOrEmpty(textResponse)) {