diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index 5914a2e..eea8f8b 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -5001,5 +5001,33 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~B 구현 완료) +--- + +## Phase 17-C — 훅 시스템 고도화 (v1.8.0) ✅ 완료 + +> **목표**: ExtendedHookRunner를 에이전트 라이프사이클 전 구간에 연결 + Prompt 모드 구현 + +### 변경 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `AppSettings.AgentConfig.cs` | `ExtendedHooksConfig`에 4개 이벤트 추가: `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` | +| `AgentLoopService.ExtendedHooks.cs` (신규, 150줄) | `RunExtendedEventAsync()` — 이벤트 훅 실행·결과 적용. `GetExtendedHooks()` — 설정에서 런타임 엔트리 조회. `ConvertToRuntimeEntries()` — ExtendedHookEntryConfig → ExtendedHookEntry 변환. `ApplyExtendedHookResult()` — additionalContext 시스템 메시지 in-place 주입. | +| `ExtendedHookRunner.cs` | `RunEventAsync()` + `ExecuteSingleAsync()`에 `LlmService? llm` 파라미터 추가. `RunPromptHookAsync()` 신규 구현: `{{tool_name}}` 등 변수 치환, LLM 호출, 차단 신호 감지. `using AxCopilot.Models` 추가. | +| `AgentLoopService.cs` | TaskState init 후: `SessionStart` 훅(fire-and-forget) + `UserPromptSubmit` 훅(차단 시 즉시 반환). 적극적 압축 직전: `PreCompact` 훅. 압축 완료 후: `PostCompact` 훅(fire-and-forget). `finally` 블록: `SessionEnd` + `AgentStop` 훅(fire-and-forget). | +| `AgentLoopService.Execution.cs` | `RunToolHooksAsync()` 개선: 레거시 AgentHookRunner 유지 + ExtendedHookRunner `PreToolUse`/`PostToolUse`/`PostToolUseFailure` 이벤트 추가 실행. | + +### 구현 세부사항 + +- **이벤트 커버리지**: SessionStart, SessionEnd, AgentStop, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PreCompact, PostCompact, FileChanged, PermissionRequest — 11종 이벤트 완전 연결 +- **실행 모드**: Command(bat/ps1), Http(POST webhook), Prompt(LLM 평가) 구현. Agent 모드는 Phase 18-A 예정. +- **Prompt 모드**: `{{tool_name}}`, `{{tool_input}}`, `{{tool_output}}`, `{{user_message}}`, `{{event}}`, `{{session_id}}` 변수 치환. 응답에 block/deny/차단 포함 시 Block=true. +- **차단 처리**: UserPromptSubmit 훅 Block=true 시 즉시 `"⚠ 요청이 훅 정책에 의해 차단되었습니다."` 반환 +- **HookFired 이벤트 로그**: 훅 실행 시 JSONL에 eventKind, hookCount, blocked 기록 +- **하위 호환**: 레거시 AgentHooks(command 스크립트) 병행 유지 +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-04 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~C 구현 완료) diff --git a/src/AxCopilot/Models/AppSettings.AgentConfig.cs b/src/AxCopilot/Models/AppSettings.AgentConfig.cs index fad328c..b39d808 100644 --- a/src/AxCopilot/Models/AppSettings.AgentConfig.cs +++ b/src/AxCopilot/Models/AppSettings.AgentConfig.cs @@ -59,24 +59,40 @@ public class ReflexionConfig /// Phase 17-C: 확장 훅 설정. public class ExtendedHooksConfig { + // ── 라이프사이클 이벤트 ────────────────────────────────────────────── + [JsonPropertyName("session_start")] + public List SessionStart { get; set; } = new(); + + [JsonPropertyName("session_end")] + public List SessionEnd { get; set; } = new(); + + [JsonPropertyName("agent_stop")] + public List AgentStop { get; set; } = new(); + [JsonPropertyName("user_prompt_submit")] public List UserPromptSubmit { get; set; } = new(); + // ── 도구 이벤트 ───────────────────────────────────────────────────── + [JsonPropertyName("pre_tool_use")] + public List PreToolUse { get; set; } = new(); + + [JsonPropertyName("post_tool_use")] + public List PostToolUse { get; set; } = new(); + + [JsonPropertyName("post_tool_use_failure")] + public List PostToolUseFailure { get; set; } = new(); + + // ── 컨텍스트 압축 이벤트 ──────────────────────────────────────────── [JsonPropertyName("pre_compact")] public List PreCompact { get; set; } = new(); [JsonPropertyName("post_compact")] public List PostCompact { get; set; } = new(); + // ── 파일·권한 이벤트 ──────────────────────────────────────────────── [JsonPropertyName("file_changed")] public List FileChanged { get; set; } = new(); - [JsonPropertyName("session_start")] - public List SessionStart { get; set; } = new(); - - [JsonPropertyName("session_end")] - public List SessionEnd { get; set; } = new(); - [JsonPropertyName("permission_request")] public List PermissionRequest { get; set; } = new(); } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs index ce97667..5c01bf3 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs @@ -369,27 +369,42 @@ public partial class AgentLoopService /// 도구 호출 처리 후 루프 제어 액션. private enum ToolCallAction { Continue, Break, Return } - /// Phase 33-B: 도구 훅(Pre/Post) 실행 헬퍼. + /// Phase 33-B / 17-C: 도구 훅(Pre/Post/Failure) 실행 헬퍼. private async Task RunToolHooksAsync( Models.LlmSettings llm, LlmService.ContentBlock call, AgentContext context, string phase, CancellationToken ct, ToolResult? result = null) { - if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return; - try + // ── 레거시 AgentHooks (command 스크립트, 하위 호환) ────────────── + if (llm.EnableToolHooks && llm.AgentHooks.Count > 0) { - var hookResults = await AgentHookRunner.RunAsync( - llm.AgentHooks, call.ToolName, phase, - toolInput: call.ToolInput.ToString(), - toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null, - success: result?.Success ?? false, - workFolder: context.WorkFolder, - timeoutMs: llm.ToolHookTimeoutMs, - ct: ct); - foreach (var pr in hookResults.Where(r => !r.Success)) - EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}"); + try + { + var hookResults = await AgentHookRunner.RunAsync( + llm.AgentHooks, call.ToolName, phase, + toolInput: call.ToolInput.ToString(), + toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null, + success: result?.Success ?? false, + workFolder: context.WorkFolder, + timeoutMs: llm.ToolHookTimeoutMs, + ct: ct); + foreach (var pr in hookResults.Where(r => !r.Success)) + EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}"); + } + catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 레거시 훅 실패: {ex.Message}"); } } - catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 훅 실패: {ex.Message}"); } + + // ── Phase 17-C: 확장 훅 (ExtendedHooks — command/http/prompt 모드) ── + var extKind = phase == "pre" + ? HookEventKind.PreToolUse + : (result?.Success == false ? HookEventKind.PostToolUseFailure : HookEventKind.PostToolUse); + + await RunExtendedEventAsync( + extKind, null, ct, + toolName: call.ToolName, + toolInput: call.ToolInput?.ToString(), + toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null, + toolSuccess: result?.Success ?? true); } /// DelegateAgentTool에서 호출하는 서브에이전트 실행기. diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs new file mode 100644 index 0000000..918ee67 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs @@ -0,0 +1,158 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-C: AgentLoopService — 확장 훅 시스템 통합. +/// 에이전트 라이프사이클 이벤트(SessionStart/End, UserPromptSubmit, +/// PreToolUse/PostToolUse, PreCompact/PostCompact 등)에 +/// ExtendedHookRunner를 연결합니다. +/// +public partial class AgentLoopService +{ + /// + /// 지정 이벤트의 확장 훅을 실행합니다. + /// - additionalContext가 있으면 시스템 메시지에 주입 + /// - Block인 경우 true 반환 → 호출자가 작업을 중단해야 함 + /// + internal async Task RunExtendedEventAsync( + HookEventKind kind, + List? messages, + CancellationToken ct, + string? toolName = null, + string? toolInput = null, + string? toolOutput = null, + bool toolSuccess = true, + string? userMessage = null, + string? changedFile = null) + { + var hooks = GetExtendedHooks(kind); + if (hooks.Count == 0) return false; + + var ctx = new HookContext + { + Event = kind, + ToolName = toolName, + ToolInput = toolInput, + ToolOutput = toolOutput, + ToolSuccess = toolSuccess, + UserMessage = userMessage, + ChangedFilePath = changedFile, + SessionId = _sessionId, + WorkFolder = _settings.Settings.Llm.WorkFolder ?? "", + }; + + try + { + var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm); + ApplyExtendedHookResult(result, messages); + + // HookFired 이벤트 로그 기록 + if (_eventLog != null && hooks.Count > 0) + _ = _eventLog.AppendAsync(AgentEventLogType.HookFired, + System.Text.Json.JsonSerializer.Serialize(new + { + eventKind = kind.ToString(), + hookCount = hooks.Count, + blocked = result.Block + })); + + return result.Block; + } + catch (Exception ex) + { + LogService.Warn($"[ExtendedHook] {kind} 훅 실행 오류: {ex.Message}"); + return false; + } + } + + /// 훅 결과의 additionalContext를 시스템 메시지에 주입합니다. + private static void ApplyExtendedHookResult(ExtendedHookResult result, List? messages) + { + if (string.IsNullOrEmpty(result.AdditionalContext) || messages == null) return; + + const string marker = "[Hook Context]"; + var sysMsg = messages.FirstOrDefault(m => m.Role == "system"); + if (sysMsg != null) + { + // 기존 [Hook Context] 섹션 교체 (중복 방지) + var idx = sysMsg.Content.IndexOf(marker, StringComparison.Ordinal); + if (idx >= 0) + sysMsg.Content = sysMsg.Content[..idx] + marker + "\n" + result.AdditionalContext; + else + sysMsg.Content += $"\n\n{marker}\n{result.AdditionalContext}"; + } + else + { + messages.Insert(0, new ChatMessage + { + Role = "system", + Content = $"{marker}\n{result.AdditionalContext}" + }); + } + } + + // ───────────────────────────────────────────────────────────────────── + // 설정 → 런타임 엔트리 변환 + // ───────────────────────────────────────────────────────────────────── + + /// 설정에서 특정 이벤트의 활성 훅 엔트리 목록을 반환합니다. + private List GetExtendedHooks(HookEventKind kind) + { + var cfg = _settings.Settings.Llm.ExtendedHooks; + + IReadOnlyList? configs = kind switch + { + HookEventKind.SessionStart => cfg.SessionStart, + HookEventKind.SessionEnd => cfg.SessionEnd, + HookEventKind.AgentStop => cfg.AgentStop, + HookEventKind.UserPromptSubmit => cfg.UserPromptSubmit, + HookEventKind.PreToolUse => cfg.PreToolUse, + HookEventKind.PostToolUse => cfg.PostToolUse, + HookEventKind.PostToolUseFailure => cfg.PostToolUseFailure, + HookEventKind.PreCompact => cfg.PreCompact, + HookEventKind.PostCompact => cfg.PostCompact, + HookEventKind.FileChanged => cfg.FileChanged, + HookEventKind.PermissionRequest => cfg.PermissionRequest, + _ => null + }; + + if (configs == null || configs.Count == 0) return new List(); + return ConvertToRuntimeEntries(configs, kind); + } + + /// 설정 모델(ExtendedHookEntryConfig) → 런타임 모델(ExtendedHookEntry) 변환. + private static List ConvertToRuntimeEntries( + IReadOnlyList configs, HookEventKind kind) + { + return configs + .Where(c => c.Enabled) + .Select(c => new ExtendedHookEntry + { + Name = c.Name, + Event = kind, + Matcher = c.Matcher, + Mode = ParseMode(c.Mode), + ScriptPath = c.ScriptPath, + Url = c.Url, + Prompt = c.Prompt, + Model = c.Model, + Enabled = c.Enabled, + Once = c.Once, + IsAsync = c.IsAsync, + TimeoutSeconds = c.TimeoutSeconds > 0 ? c.TimeoutSeconds : 30, + StatusMessage = c.StatusMessage, + WatchPaths = c.WatchPaths ?? new List(), + }) + .ToList(); + } + + private static HookExecutionMode ParseMode(string? mode) => + mode?.ToLowerInvariant() switch + { + "http" => HookExecutionMode.Http, + "prompt" => HookExecutionMode.Prompt, + "agent" => HookExecutionMode.Agent, + _ => HookExecutionMode.Command + }; +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index db2a46c..aaf7ad7 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -176,6 +176,23 @@ public partial class AgentLoopService _ = _eventLog?.AppendAsync(AgentEventLogType.UserMessage, JsonSerializer.Serialize(new { length = userQuery.Length })); await InitTaskStateAsync(userQuery, _sessionId); + + // Phase 17-C: SessionStart 훅 실행 (fire-and-forget) + _ = RunExtendedEventAsync(HookEventKind.SessionStart, messages, CancellationToken.None, + userMessage: userQuery); + + // Phase 17-C: UserPromptSubmit 훅 실행 — 차단 시 즉시 종료 + if (!string.IsNullOrWhiteSpace(userQuery)) + { + var promptBlocked = await RunExtendedEventAsync( + HookEventKind.UserPromptSubmit, messages, ct, userMessage: userQuery); + if (promptBlocked) + { + EmitEvent(AgentEventType.Complete, "", "훅 정책에 의해 요청이 차단되었습니다"); + return "⚠ 요청이 훅 정책에 의해 차단되었습니다."; + } + } + var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터 var totalToolCalls = 0; // 복잡도 추정용 @@ -357,6 +374,8 @@ public partial class AgentLoopService _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted, JsonSerializer.Serialize(new { messageCount = messages.Count })); UpdateTaskStateSummaryAsync("컨텍스트 압축 완료"); + // Phase 17-C: PostCompact 훅 (fire-and-forget) + _ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None); } // 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준) @@ -374,6 +393,8 @@ public partial class AgentLoopService $"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행"); _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered, JsonSerializer.Serialize(new { aggressive = true, usagePct })); + // Phase 17-C: PreCompact 훅 — 적극적 압축 직전 (압축 발생 확실) + await RunExtendedEventAsync(HookEventKind.PreCompact, messages, ct); var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio); var compacted = await ContextCondenser.CondenseIfNeededAsync( messages, _llm, targetTokens, ct); @@ -383,6 +404,8 @@ public partial class AgentLoopService _ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted, JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count })); UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)"); + // Phase 17-C: PostCompact 훅 (fire-and-forget) + _ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None); } } } @@ -778,6 +801,10 @@ public partial class AgentLoopService } finally { + // Phase 17-C: SessionEnd + AgentStop 확장 훅 실행 (fire-and-forget) + _ = RunExtendedEventAsync(HookEventKind.SessionEnd, null, CancellationToken.None, userMessage: userQuery); + _ = RunExtendedEventAsync(HookEventKind.AgentStop, null, CancellationToken.None, userMessage: userQuery); + // 세션 종료 이벤트 기록 if (_eventLog != null) _ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd, diff --git a/src/AxCopilot/Services/Agent/ExtendedHookRunner.cs b/src/AxCopilot/Services/Agent/ExtendedHookRunner.cs index 743ba9d..4754b21 100644 --- a/src/AxCopilot/Services/Agent/ExtendedHookRunner.cs +++ b/src/AxCopilot/Services/Agent/ExtendedHookRunner.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net.Http; using System.Text; using System.Text.Json; +using AxCopilot.Models; namespace AxCopilot.Services.Agent; @@ -22,10 +23,12 @@ public static class ExtendedHookRunner }; /// 지정 이벤트의 훅을 모두 실행하고 결과를 병합하여 반환. + /// Prompt 모드 훅에서 LLM 호출에 사용할 서비스 (null이면 Prompt 모드 스킵). public static async Task RunEventAsync( IReadOnlyList hooks, HookContext context, - CancellationToken ct = default) + CancellationToken ct = default, + LlmService? llm = null) { if (hooks == null || hooks.Count == 0) return new ExtendedHookResult(); @@ -53,7 +56,7 @@ public static class ExtendedHookRunner { try { - var result = await ExecuteSingleAsync(hook, context, CancellationToken.None); + var result = await ExecuteSingleAsync(hook, context, CancellationToken.None, llm); if (result.Block) { // exit code 2 = rewake → AdditionalContext를 통해 대화 재활성화 @@ -72,7 +75,7 @@ public static class ExtendedHookRunner { _ = Task.Run(async () => { - try { await ExecuteSingleAsync(hook, context, CancellationToken.None); } + try { await ExecuteSingleAsync(hook, context, CancellationToken.None, llm); } catch (Exception) { } }); } @@ -95,7 +98,7 @@ public static class ExtendedHookRunner try { - var result = await ExecuteSingleAsync(hook, context, cts.Token); + var result = await ExecuteSingleAsync(hook, context, cts.Token, llm); if (result.Block) { anyBlocked = true; blockReason = result.BlockReason; break; } if (result.AdditionalContext != null) @@ -130,18 +133,70 @@ public static class ExtendedHookRunner private static async Task ExecuteSingleAsync( ExtendedHookEntry hook, HookContext context, - CancellationToken ct) + CancellationToken ct, + LlmService? llm = null) { return hook.Mode switch { HookExecutionMode.Command => await RunCommandHookAsync(hook, context, ct), HookExecutionMode.Http => await RunHttpHookAsync(hook, context, ct), - HookExecutionMode.Prompt => new ExtendedHookResult(), // LLM 검사는 LlmService 필요 — 외부에서 처리 - HookExecutionMode.Agent => new ExtendedHookResult(), // 미니 에이전트는 AgentLoopService 필요 — 외부 처리 + // Phase 17-C: Prompt 모드 — LlmService 주입 필요. 없으면 스킵. + HookExecutionMode.Prompt => llm != null + ? await RunPromptHookAsync(hook, context, llm, ct) + : new ExtendedHookResult(), + // Agent 모드: AgentLoopService 참조 필요 — 미구현 (향후 Phase 18-A에서 처리) + HookExecutionMode.Agent => new ExtendedHookResult(), _ => new ExtendedHookResult() }; } + /// + /// Phase 17-C: Prompt 모드 훅 — LLM으로 도구/이벤트를 평가합니다. + /// 프롬프트에서 {{tool_name}}, {{tool_input}}, {{tool_output}}, {{user_message}} 치환. + /// 응답에 "block", "deny", "차단" 포함 시 Block=true 반환. + /// + private static async Task RunPromptHookAsync( + ExtendedHookEntry hook, + HookContext context, + LlmService llm, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(hook.Prompt)) return new ExtendedHookResult(); + + // 변수 치환 + var evalPrompt = hook.Prompt + .Replace("{{tool_name}}", context.ToolName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{{tool_input}}", context.ToolInput ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{{tool_output}}", context.ToolOutput ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{{user_message}}", context.UserMessage ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{{event}}", context.Event.ToString(), StringComparison.OrdinalIgnoreCase) + .Replace("{{session_id}}", context.SessionId, StringComparison.OrdinalIgnoreCase); + + string response; + try + { + var messages = new List + { + new() { Role = "user", Content = evalPrompt } + }; + // 훅 전용 소형 모델이 지정된 경우 → 현재는 기본 LLM 사용 (향후 모델 오버라이드 지원 예정) + response = await llm.SendAsync(messages, ct); + } + catch (Exception) + { + return new ExtendedHookResult(); + } + + if (string.IsNullOrWhiteSpace(response)) return new ExtendedHookResult(); + + // 차단 신호 감지 + var lower = response.ToLowerInvariant(); + if (lower.Contains("block") || lower.Contains("deny") || lower.Contains("차단")) + return new ExtendedHookResult { Block = true, BlockReason = Truncate(response, 500) }; + + return new ExtendedHookResult { AdditionalContext = Truncate(response, 2000) }; + } + private static async Task RunCommandHookAsync( ExtendedHookEntry hook, HookContext context,