[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>
|
/// <summary>Phase 17-C: 확장 훅 설정.</summary>
|
||||||
public class ExtendedHooksConfig
|
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")]
|
[JsonPropertyName("user_prompt_submit")]
|
||||||
public List<ExtendedHookEntryConfig> UserPromptSubmit { get; set; } = new();
|
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")]
|
[JsonPropertyName("pre_compact")]
|
||||||
public List<ExtendedHookEntryConfig> PreCompact { get; set; } = new();
|
public List<ExtendedHookEntryConfig> PreCompact { get; set; } = new();
|
||||||
|
|
||||||
[JsonPropertyName("post_compact")]
|
[JsonPropertyName("post_compact")]
|
||||||
public List<ExtendedHookEntryConfig> PostCompact { get; set; } = new();
|
public List<ExtendedHookEntryConfig> PostCompact { get; set; } = new();
|
||||||
|
|
||||||
|
// ── 파일·권한 이벤트 ────────────────────────────────────────────────
|
||||||
[JsonPropertyName("file_changed")]
|
[JsonPropertyName("file_changed")]
|
||||||
public List<ExtendedHookEntryConfig> FileChanged { get; set; } = new();
|
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")]
|
[JsonPropertyName("permission_request")]
|
||||||
public List<ExtendedHookEntryConfig> PermissionRequest { get; set; } = new();
|
public List<ExtendedHookEntryConfig> PermissionRequest { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,27 +369,42 @@ public partial class AgentLoopService
|
|||||||
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
|
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
|
||||||
private enum ToolCallAction { Continue, Break, Return }
|
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(
|
private async Task RunToolHooksAsync(
|
||||||
Models.LlmSettings llm, LlmService.ContentBlock call,
|
Models.LlmSettings llm, LlmService.ContentBlock call,
|
||||||
AgentContext context, string phase, CancellationToken ct,
|
AgentContext context, string phase, CancellationToken ct,
|
||||||
ToolResult? result = null)
|
ToolResult? result = null)
|
||||||
{
|
{
|
||||||
if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return;
|
// ── 레거시 AgentHooks (command 스크립트, 하위 호환) ──────────────
|
||||||
try
|
if (llm.EnableToolHooks && llm.AgentHooks.Count > 0)
|
||||||
{
|
{
|
||||||
var hookResults = await AgentHookRunner.RunAsync(
|
try
|
||||||
llm.AgentHooks, call.ToolName, phase,
|
{
|
||||||
toolInput: call.ToolInput.ToString(),
|
var hookResults = await AgentHookRunner.RunAsync(
|
||||||
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
|
llm.AgentHooks, call.ToolName, phase,
|
||||||
success: result?.Success ?? false,
|
toolInput: call.ToolInput.ToString(),
|
||||||
workFolder: context.WorkFolder,
|
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
|
||||||
timeoutMs: llm.ToolHookTimeoutMs,
|
success: result?.Success ?? false,
|
||||||
ct: ct);
|
workFolder: context.WorkFolder,
|
||||||
foreach (var pr in hookResults.Where(r => !r.Success))
|
timeoutMs: llm.ToolHookTimeoutMs,
|
||||||
EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}");
|
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>
|
/// <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,
|
_ = _eventLog?.AppendAsync(AgentEventLogType.UserMessage,
|
||||||
JsonSerializer.Serialize(new { length = userQuery.Length }));
|
JsonSerializer.Serialize(new { length = userQuery.Length }));
|
||||||
await InitTaskStateAsync(userQuery, _sessionId);
|
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 consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
|
||||||
var totalToolCalls = 0; // 복잡도 추정용
|
var totalToolCalls = 0; // 복잡도 추정용
|
||||||
|
|
||||||
@@ -357,6 +374,8 @@ public partial class AgentLoopService
|
|||||||
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
||||||
JsonSerializer.Serialize(new { messageCount = messages.Count }));
|
JsonSerializer.Serialize(new { messageCount = messages.Count }));
|
||||||
UpdateTaskStateSummaryAsync("컨텍스트 압축 완료");
|
UpdateTaskStateSummaryAsync("컨텍스트 압축 완료");
|
||||||
|
// Phase 17-C: PostCompact 훅 (fire-and-forget)
|
||||||
|
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준)
|
// 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준)
|
||||||
@@ -374,6 +393,8 @@ public partial class AgentLoopService
|
|||||||
$"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행");
|
$"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행");
|
||||||
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered,
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered,
|
||||||
JsonSerializer.Serialize(new { aggressive = true, usagePct }));
|
JsonSerializer.Serialize(new { aggressive = true, usagePct }));
|
||||||
|
// Phase 17-C: PreCompact 훅 — 적극적 압축 직전 (압축 발생 확실)
|
||||||
|
await RunExtendedEventAsync(HookEventKind.PreCompact, messages, ct);
|
||||||
var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio);
|
var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio);
|
||||||
var compacted = await ContextCondenser.CondenseIfNeededAsync(
|
var compacted = await ContextCondenser.CondenseIfNeededAsync(
|
||||||
messages, _llm, targetTokens, ct);
|
messages, _llm, targetTokens, ct);
|
||||||
@@ -383,6 +404,8 @@ public partial class AgentLoopService
|
|||||||
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
|
||||||
JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count }));
|
JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count }));
|
||||||
UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)");
|
UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)");
|
||||||
|
// Phase 17-C: PostCompact 훅 (fire-and-forget)
|
||||||
|
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,6 +801,10 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
finally
|
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)
|
if (_eventLog != null)
|
||||||
_ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd,
|
_ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
@@ -22,10 +23,12 @@ public static class ExtendedHookRunner
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>지정 이벤트의 훅을 모두 실행하고 결과를 병합하여 반환.</summary>
|
/// <summary>지정 이벤트의 훅을 모두 실행하고 결과를 병합하여 반환.</summary>
|
||||||
|
/// <param name="llm">Prompt 모드 훅에서 LLM 호출에 사용할 서비스 (null이면 Prompt 모드 스킵).</param>
|
||||||
public static async Task<ExtendedHookResult> RunEventAsync(
|
public static async Task<ExtendedHookResult> RunEventAsync(
|
||||||
IReadOnlyList<ExtendedHookEntry> hooks,
|
IReadOnlyList<ExtendedHookEntry> hooks,
|
||||||
HookContext context,
|
HookContext context,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default,
|
||||||
|
LlmService? llm = null)
|
||||||
{
|
{
|
||||||
if (hooks == null || hooks.Count == 0)
|
if (hooks == null || hooks.Count == 0)
|
||||||
return new ExtendedHookResult();
|
return new ExtendedHookResult();
|
||||||
@@ -53,7 +56,7 @@ public static class ExtendedHookRunner
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await ExecuteSingleAsync(hook, context, CancellationToken.None);
|
var result = await ExecuteSingleAsync(hook, context, CancellationToken.None, llm);
|
||||||
if (result.Block)
|
if (result.Block)
|
||||||
{
|
{
|
||||||
// exit code 2 = rewake → AdditionalContext를 통해 대화 재활성화
|
// exit code 2 = rewake → AdditionalContext를 통해 대화 재활성화
|
||||||
@@ -72,7 +75,7 @@ public static class ExtendedHookRunner
|
|||||||
{
|
{
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try { await ExecuteSingleAsync(hook, context, CancellationToken.None); }
|
try { await ExecuteSingleAsync(hook, context, CancellationToken.None, llm); }
|
||||||
catch (Exception) { }
|
catch (Exception) { }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,7 @@ public static class ExtendedHookRunner
|
|||||||
|
|
||||||
try
|
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.Block) { anyBlocked = true; blockReason = result.BlockReason; break; }
|
||||||
if (result.AdditionalContext != null)
|
if (result.AdditionalContext != null)
|
||||||
@@ -130,18 +133,70 @@ public static class ExtendedHookRunner
|
|||||||
private static async Task<ExtendedHookResult> ExecuteSingleAsync(
|
private static async Task<ExtendedHookResult> ExecuteSingleAsync(
|
||||||
ExtendedHookEntry hook,
|
ExtendedHookEntry hook,
|
||||||
HookContext context,
|
HookContext context,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
LlmService? llm = null)
|
||||||
{
|
{
|
||||||
return hook.Mode switch
|
return hook.Mode switch
|
||||||
{
|
{
|
||||||
HookExecutionMode.Command => await RunCommandHookAsync(hook, context, ct),
|
HookExecutionMode.Command => await RunCommandHookAsync(hook, context, ct),
|
||||||
HookExecutionMode.Http => await RunHttpHookAsync(hook, context, ct),
|
HookExecutionMode.Http => await RunHttpHookAsync(hook, context, ct),
|
||||||
HookExecutionMode.Prompt => new ExtendedHookResult(), // LLM 검사는 LlmService 필요 — 외부에서 처리
|
// Phase 17-C: Prompt 모드 — LlmService 주입 필요. 없으면 스킵.
|
||||||
HookExecutionMode.Agent => new ExtendedHookResult(), // 미니 에이전트는 AgentLoopService 필요 — 외부 처리
|
HookExecutionMode.Prompt => llm != null
|
||||||
|
? await RunPromptHookAsync(hook, context, llm, ct)
|
||||||
|
: new ExtendedHookResult(),
|
||||||
|
// Agent 모드: AgentLoopService 참조 필요 — 미구현 (향후 Phase 18-A에서 처리)
|
||||||
|
HookExecutionMode.Agent => new ExtendedHookResult(),
|
||||||
_ => 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(
|
private static async Task<ExtendedHookResult> RunCommandHookAsync(
|
||||||
ExtendedHookEntry hook,
|
ExtendedHookEntry hook,
|
||||||
HookContext context,
|
HookContext context,
|
||||||
|
|||||||
Reference in New Issue
Block a user