[Phase 17-B] TaskState Working Memory + 이벤트 로그 완전 통합

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 23:22:40 +09:00
parent 90d5943327
commit 2383b1e220
4 changed files with 162 additions and 1 deletions

View File

@@ -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 구현 완료)

View File

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

View File

@@ -0,0 +1,108 @@
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 17-B: AgentLoopService — TaskState Working Memory + 이벤트 로그 통합.
/// 세션 전반에 걸쳐 구조화된 작업 상태를 유지하고,
/// 컨텍스트 압축(auto-compact) 시에도 핵심 작업 정보를 보존합니다.
/// </summary>
public partial class AgentLoopService
{
// ── 지연 초기화 (첫 사용 시 생성) ────────────────────────────────────
private TaskStateService? _taskState;
/// <summary>현재 세션의 TaskState Working Memory 서비스.</summary>
private TaskStateService TaskStateManager => _taskState ??= new TaskStateService();
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 세션 시작 시 TaskState를 초기화합니다.
/// 기존 세션 ID 파일이 있으면 로드, 없으면 새로 생성합니다.
/// </summary>
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}");
}
}
/// <summary>
/// 파일 관련 도구 성공 시 반환된 파일 경로를 Working Memory에 추가합니다.
/// fire-and-forget으로 실행하여 에이전트 루프를 차단하지 않습니다.
/// </summary>
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) { /* 추적 실패는 루프에 영향 없음 */ }
});
}
/// <summary>
/// 컨텍스트 압축 전 TaskState Working Memory를 시스템 메시지에 주입합니다.
/// 기존 Working Memory 섹션이 있으면 in-place 교체하여 중복 추가를 방지합니다.
/// </summary>
internal void InjectTaskStateContext(List<ChatMessage> 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}");
}
}
/// <summary>
/// 컨텍스트 압축 완료 후 TaskState ContextSummary를 비동기 갱신합니다.
/// fire-and-forget으로 실행하여 루프를 차단하지 않습니다.
/// </summary>
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) { /* 저장 실패는 무시 */ }
});
}
}

View File

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