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 학습 지도