[Phase 17-C] 훅 시스템 고도화 — 11종 이벤트 연결 + Prompt 모드 구현
AppSettings.AgentConfig.cs:
- ExtendedHooksConfig에 4개 이벤트 추가:
preToolUse, postToolUse, postToolUseFailure, agentStop
AgentLoopService.ExtendedHooks.cs (신규, 150줄):
- RunExtendedEventAsync(): 이벤트 훅 실행, 결과(additionalContext) 시스템 메시지 주입, HookFired 이벤트 로그
- GetExtendedHooks(): 설정에서 HookEventKind별 런타임 엔트리 조회
- ConvertToRuntimeEntries(): ExtendedHookEntryConfig → ExtendedHookEntry 변환
- ApplyExtendedHookResult(): additionalContext in-place 주입 (marker 기반 교체)
ExtendedHookRunner.cs:
- RunEventAsync() / ExecuteSingleAsync()에 LlmService? llm 파라미터 추가
- RunPromptHookAsync() 신규: {{tool_name/input/output/user_message/event/session_id}} 치환
→ LLM 호출 → block/deny/차단 감지 시 Block=true 반환
- using AxCopilot.Models 추가
AgentLoopService.cs:
- SessionStart 훅 (fire-and-forget, TaskState init 직후)
- UserPromptSubmit 훅 (동기, Block=true 시 즉시 반환 "⚠ 훅 정책에 의해 차단")
- PreCompact 훅 (적극적 압축 직전, 동기)
- PostCompact 훅 × 2 (기본/적극적 압축 완료 후, fire-and-forget)
- SessionEnd + AgentStop 훅 (finally 블록, fire-and-forget)
AgentLoopService.Execution.cs:
- RunToolHooksAsync() 개선: 레거시 AgentHookRunner 유지
+ ExtendedHookRunner PreToolUse/PostToolUse/PostToolUseFailure 추가
이벤트 커버리지: 11종 완전 연결. Agent 모드는 Phase 18-A 예정.
빌드: 경고 0, 오류 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 구현 완료)
|
||||
|
||||
|
||||
@@ -59,24 +59,40 @@ public class ReflexionConfig
|
||||
/// <summary>Phase 17-C: 확장 훅 설정.</summary>
|
||||
public class ExtendedHooksConfig
|
||||
{
|
||||
// ── 라이프사이클 이벤트 ──────────────────────────────────────────────
|
||||
[JsonPropertyName("session_start")]
|
||||
public List<ExtendedHookEntryConfig> SessionStart { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("session_end")]
|
||||
public List<ExtendedHookEntryConfig> SessionEnd { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("agent_stop")]
|
||||
public List<ExtendedHookEntryConfig> AgentStop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_prompt_submit")]
|
||||
public List<ExtendedHookEntryConfig> UserPromptSubmit { get; set; } = new();
|
||||
|
||||
// ── 도구 이벤트 ─────────────────────────────────────────────────────
|
||||
[JsonPropertyName("pre_tool_use")]
|
||||
public List<ExtendedHookEntryConfig> PreToolUse { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("post_tool_use")]
|
||||
public List<ExtendedHookEntryConfig> PostToolUse { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("post_tool_use_failure")]
|
||||
public List<ExtendedHookEntryConfig> PostToolUseFailure { get; set; } = new();
|
||||
|
||||
// ── 컨텍스트 압축 이벤트 ────────────────────────────────────────────
|
||||
[JsonPropertyName("pre_compact")]
|
||||
public List<ExtendedHookEntryConfig> PreCompact { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("post_compact")]
|
||||
public List<ExtendedHookEntryConfig> PostCompact { get; set; } = new();
|
||||
|
||||
// ── 파일·권한 이벤트 ────────────────────────────────────────────────
|
||||
[JsonPropertyName("file_changed")]
|
||||
public List<ExtendedHookEntryConfig> FileChanged { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("session_start")]
|
||||
public List<ExtendedHookEntryConfig> SessionStart { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("session_end")]
|
||||
public List<ExtendedHookEntryConfig> SessionEnd { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("permission_request")]
|
||||
public List<ExtendedHookEntryConfig> PermissionRequest { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -369,27 +369,42 @@ public partial class AgentLoopService
|
||||
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
|
||||
private enum ToolCallAction { Continue, Break, Return }
|
||||
|
||||
/// <summary>Phase 33-B: 도구 훅(Pre/Post) 실행 헬퍼.</summary>
|
||||
/// <summary>Phase 33-B / 17-C: 도구 훅(Pre/Post/Failure) 실행 헬퍼.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>DelegateAgentTool에서 호출하는 서브에이전트 실행기.</summary>
|
||||
|
||||
158
src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs
Normal file
158
src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 17-C: AgentLoopService — 확장 훅 시스템 통합.
|
||||
/// 에이전트 라이프사이클 이벤트(SessionStart/End, UserPromptSubmit,
|
||||
/// PreToolUse/PostToolUse, PreCompact/PostCompact 등)에
|
||||
/// ExtendedHookRunner를 연결합니다.
|
||||
/// </summary>
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
/// <summary>
|
||||
/// 지정 이벤트의 확장 훅을 실행합니다.
|
||||
/// - additionalContext가 있으면 시스템 메시지에 주입
|
||||
/// - Block인 경우 true 반환 → 호출자가 작업을 중단해야 함
|
||||
/// </summary>
|
||||
internal async Task<bool> RunExtendedEventAsync(
|
||||
HookEventKind kind,
|
||||
List<ChatMessage>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>훅 결과의 additionalContext를 시스템 메시지에 주입합니다.</summary>
|
||||
private static void ApplyExtendedHookResult(ExtendedHookResult result, List<ChatMessage>? 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}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 설정 → 런타임 엔트리 변환
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>설정에서 특정 이벤트의 활성 훅 엔트리 목록을 반환합니다.</summary>
|
||||
private List<ExtendedHookEntry> GetExtendedHooks(HookEventKind kind)
|
||||
{
|
||||
var cfg = _settings.Settings.Llm.ExtendedHooks;
|
||||
|
||||
IReadOnlyList<ExtendedHookEntryConfig>? 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<ExtendedHookEntry>();
|
||||
return ConvertToRuntimeEntries(configs, kind);
|
||||
}
|
||||
|
||||
/// <summary>설정 모델(ExtendedHookEntryConfig) → 런타임 모델(ExtendedHookEntry) 변환.</summary>
|
||||
private static List<ExtendedHookEntry> ConvertToRuntimeEntries(
|
||||
IReadOnlyList<ExtendedHookEntryConfig> 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<string>(),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HookExecutionMode ParseMode(string? mode) =>
|
||||
mode?.ToLowerInvariant() switch
|
||||
{
|
||||
"http" => HookExecutionMode.Http,
|
||||
"prompt" => HookExecutionMode.Prompt,
|
||||
"agent" => HookExecutionMode.Agent,
|
||||
_ => HookExecutionMode.Command
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
/// <summary>지정 이벤트의 훅을 모두 실행하고 결과를 병합하여 반환.</summary>
|
||||
/// <param name="llm">Prompt 모드 훅에서 LLM 호출에 사용할 서비스 (null이면 Prompt 모드 스킵).</param>
|
||||
public static async Task<ExtendedHookResult> RunEventAsync(
|
||||
IReadOnlyList<ExtendedHookEntry> 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<ExtendedHookResult> 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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 17-C: Prompt 모드 훅 — LLM으로 도구/이벤트를 평가합니다.
|
||||
/// 프롬프트에서 {{tool_name}}, {{tool_input}}, {{tool_output}}, {{user_message}} 치환.
|
||||
/// 응답에 "block", "deny", "차단" 포함 시 Block=true 반환.
|
||||
/// </summary>
|
||||
private static async Task<ExtendedHookResult> 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<ChatMessage>
|
||||
{
|
||||
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<ExtendedHookResult> RunCommandHookAsync(
|
||||
ExtendedHookEntry hook,
|
||||
HookContext context,
|
||||
|
||||
Reference in New Issue
Block a user