research

ZeroClaw — Lifecycle Hooks

ZeroClaw — Lifecycle Hooks

Layer 3 학습. 실제 소스 코드 기반. 파일: src/hooks/traits.rs, src/hooks/runner.rs, src/hooks/builtin/


한 줄 정의

Hooks = 에이전트 파이프라인의 특정 지점에서 코드를 끼워 넣는 확장 포인트.

메시지 수신 → LLM 호출 → 도구 실행 → 응답 전송의 각 단계마다 커스텀 로직(로깅, 필터링, 수정, 차단)을 삽입할 수 있어.


두 종류의 Hook

1. Void Hooks — 병렬, fire-and-forget

모든 핸들러가 동시에(join_all) 실행
결과가 파이프라인에 영향 없음
에러/패닉이 있어도 무시

| 메서드 | 트리거 시점 | |--------|------------| | on_gateway_start(host, port) | Gateway HTTP 서버 시작 시 | | on_gateway_stop() | Gateway 종료 시 | | on_session_start(session_id, channel) | WS 채팅 세션 시작 | | on_session_end(session_id, channel) | WS 채팅 세션 종료 | | on_llm_input(messages, model) | LLM 호출 직전 (메시지 배열) | | on_llm_output(response) | LLM 응답 수신 직후 | | on_after_tool_call(tool, result, duration) | 도구 실행 완료 후 | | on_message_sent(channel, recipient, content) | 채널로 메시지 발송 후 | | on_heartbeat_tick() | Heartbeat 워커 틱마다 |

2. Modifying Hooks — 순차(priority 순), 단락 가능

핸들러들이 priority 내림차순으로 순서대로 실행
각 핸들러의 출력이 다음 핸들러의 입력으로 파이핑
HookResult::Cancel 반환 시 즉시 중단 → 파이프라인 취소
패닉 발생 시 해당 핸들러 건너뛰고 이전 값 유지

| 메서드 | 수정 가능 데이터 | 용도 | |--------|----------------|------| | before_model_resolve(provider, model) | provider, model 문자열 | 모델/provider 동적 변경 | | before_prompt_build(prompt) | system prompt 문자열 | 프롬프트 주입/검열 | | before_llm_call(messages, model) | 메시지 배열 + 모델 | 대화 기록 수정/차단 | | before_tool_call(name, args) | 도구명 + 인수 JSON | 도구 실행 차단/수정 | | on_message_received(message) | ChannelMessage | 수신 메시지 필터링/변환 | | on_message_sending(channel, recipient, content) | 응답 내용 | 발신 응답 수정/억제 |


HookResult<T>

pub enum HookResult<T> {
    Continue(T),       // 수정된(혹은 원본) 데이터로 계속
    Cancel(String),    // 이유 문자열과 함께 파이프라인 취소
}

Cancel의 실제 효과:

  • before_tool_call → 도구 실행 안 됨
  • on_message_received → 메시지 처리 안 됨 (return)
  • on_message_sending → 응답 발송 안 됨 (억제)
  • before_llm_call → LLM 호출 안 됨

HookHandler trait

#[async_trait]
pub trait HookHandler: Send + Sync {
    fn name(&self) -> &str;          // 필수
    fn priority(&self) -> i32 {       // 기본 0 (높을수록 먼저 실행)
        0
    }

    // 원하는 메서드만 override — 나머지는 noop/pass-through
    async fn before_tool_call(&self, name: String, args: Value)
        -> HookResult<(String, Value)> {
        HookResult::Continue((name, args))
    }
    // ...
}

HookRunner — 디스패처

pub struct HookRunner {
    handlers: Vec<Box<dyn HookHandler>>,  // priority 내림차순 정렬
}

// 등록 시 자동 정렬
runner.register(Box::new(MyHook));

Void 디스패처:

// join_all로 모든 핸들러 동시 실행
pub async fn fire_heartbeat_tick(&self) {
    join_all(self.handlers.iter().map(|h| h.on_heartbeat_tick())).await;
}

Modifying 디스패처:

// 순서대로 실행, 각 결과를 다음 입력으로
pub async fn run_before_prompt_build(&self, mut prompt: String) -> HookResult<String> {
    for h in &self.handlers {
        match AssertUnwindSafe(h.before_prompt_build(prompt.clone()))
            .catch_unwind().await
        {
            Ok(HookResult::Continue(p)) => prompt = p,  // 파이핑
            Ok(HookResult::Cancel(reason)) => return HookResult::Cancel(reason), // 단락
            Err(_) => { /* 패닉 → 이전 값 유지, 계속 */ }
        }
    }
    HookResult::Continue(prompt)
}

catch_unwind로 개별 핸들러 패닉이 전체 파이프라인을 죽이지 않도록 방어.


내장 Hook 2종

CommandLoggerHook

// priority = -50 (낮음, 나중에 실행)
// on_after_tool_call 만 구현

async fn on_after_tool_call(&self, tool: &str, result: &ToolResult, duration: Duration) {
    // "[14:30:00] shell (42ms) success=true"
    // tracing::info + 인메모리 log Vec 저장
}

config.toml 활성화:

[hooks]
enabled = true
[hooks.builtin]
command_logger = true

WebhookAuditHook

도구 실행 시 외부 HTTP 엔드포인트로 감사 이벤트를 전송.

// priority = -100 (가장 낮음)
// before_tool_call: 인수 캡처 (include_args = true 시)
// on_after_tool_call: 결과를 HTTP POST

// 페이로드:
{
    "event": "tool_call",
    "timestamp": "2026-03-22T10:00:00Z",
    "tool": "shell",
    "success": true,
    "duration_ms": 42,
    "error": null,
    "args": {"command": "ls"}  // include_args = true 시
}

SSRF 방어:

HTTPS 필수 (debug 모드에서 localhost HTTP 예외)
loopback 차단: 127.0.0.0/8, ::1
링크로컬 차단: 169.254.0.0/16, fe80::/10
RFC1918 차단: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16

글로브 패턴으로 감사할 도구 선택:

[hooks.builtin.webhook_audit]
enabled = true
url = "https://audit.example.com/hook"
tool_patterns = ["shell", "file_write", "mcp__*"]
include_args = true
max_args_bytes = 4096  # 0 = 무제한

파이프라인 내 Hook 위치

채널 메시지 수신
    │
    ├─ on_message_received()          ← 수신 필터 (modifying)
    │   Cancel → 메시지 드롭
    │
    ├─ before_model_resolve()         ← 모델 라우팅 조정 (modifying)
    │
    ├─ before_prompt_build()          ← 시스템 프롬프트 수정 (modifying)
    │
    ├─ before_llm_call()              ← LLM 직전 메시지 수정 (modifying)
    │   Cancel → LLM 호출 안 됨
    │
    ├─ on_llm_input()                 ← LLM 입력 로깅 (void)
    │
    │   [LLM 호출]
    │
    ├─ on_llm_output()                ← LLM 출력 로깅 (void)
    │
    │   [Tool Call Loop]
    │   ├─ before_tool_call()         ← 도구 실행 차단/수정 (modifying)
    │   │   Cancel → 도구 실행 안 됨
    │   │   [도구 실행]
    │   └─ on_after_tool_call()       ← 실행 결과 감사 (void)
    │       WebhookAuditHook: HTTP POST
    │       CommandLoggerHook: 로컬 로그
    │
    └─ on_message_sending()           ← 응답 발신 전 (modifying)
        Cancel → 응답 억제
        [채널 send()]

커스텀 Hook 구현 예시

use zeroclaw::hooks::traits::{HookHandler, HookResult};

struct PiiFilterHook;

#[async_trait]
impl HookHandler for PiiFilterHook {
    fn name(&self) -> &str { "pii-filter" }
    fn priority(&self) -> i32 { 50 }  // 높은 우선순위 — 먼저 실행

    // 응답에서 주민등록번호 패턴 제거
    async fn on_message_sending(
        &self, channel: String, recipient: String, mut content: String,
    ) -> HookResult<(String, String, String)> {
        static RE: LazyLock<Regex> = LazyLock::new(||
            Regex::new(r"\d{6}-[1-4]\d{6}").unwrap()
        );
        content = RE.replace_all(&content, "[REDACTED]").into_owned();
        HookResult::Continue((channel, recipient, content))
    }
}

// 등록
let mut runner = HookRunner::new();
runner.register(Box::new(PiiFilterHook));

config.toml 설정

[hooks]
enabled = true          # false이면 모든 hook 비활성화

[hooks.builtin]
command_logger = true   # 도구 실행 로컬 로깅

[hooks.builtin.webhook_audit]
enabled = true
url = "https://your-siem.example.com/events"
tool_patterns = ["shell", "file_write", "file_delete", "mcp__*"]
include_args = true
max_args_bytes = 2048   # 큰 args 트런케이트

관련

  • channel-system — on_message_received, on_message_sending 실제 호출 위치
  • agent-loop — before_llm_call, before_tool_call, on_after_tool_call 호출 위치
  • gateway — on_gateway_start 호출 위치
  • daemon — Heartbeat에서 on_heartbeat_tick 호출
  • config-schema — [hooks] 설정 섹션
  • overview — ZeroClaw 학습 지도