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