# Hook 系统使用说明 这份文档对应当前仓库里已经实现的 hook 系统,而不是设计草案。 当前实现支持两类挂载方式: 1. 进程内 hook 2. 进程外 process hook(`JSON-RPC over stdio`) 当前仓库不再内置示例代码文件。下面的 Go / Python 示例都直接写在本文档里;如果你要使用它们,需要先复制到你自己的文件路径。 ## 支持的 hook 类型 | 类型 | 接口 | 作用阶段 | 能否改写 | | --- | --- | --- | --- | | 观察型 | `EventObserver` | EventBus 广播事件时 | 否 | | LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 | | Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 | | Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 | 当前公开的同步点位只有: - `before_llm` - `after_llm` - `before_tool` - `after_tool` - `approve_tool` 其余 lifecycle 通过事件形式只读暴露。 ## 执行顺序 HookManager 的排序规则是: 1. 先执行进程内 hook 2. 再执行 process hook 3. 同一来源内按 `priority` 从小到大 4. 若 `priority` 相同,再按名字排序 ## 超时 当前配置在 `hooks.defaults` 中统一设置: - `observer_timeout_ms` - `interceptor_timeout_ms` - `approval_timeout_ms` 注意:当前实现还没有单个 process hook 自己的 `timeout_ms` 字段,超时配置是全局默认值。 ## 快速开始 如果你的目标只是先把当前 hook 流程跑通并观察到实际请求,最省事的是先用下面的 Python process hook 示例: 1. 打开 `hooks.enabled` 2. 把下面文档里的 Python 示例保存到本地文件,例如 `/tmp/review_gate.py` 3. 给它配置 `PICOCLAW_HOOK_LOG_FILE` 4. 重启 gateway 5. 用 `tail -f` 观察日志文件 例如: ```json { "hooks": { "enabled": true, "processes": { "py_review_gate": { "enabled": true, "priority": 100, "transport": "stdio", "command": [ "python3", "/tmp/review_gate.py" ], "observe": [ "tool_exec_start", "tool_exec_end", "tool_exec_skipped" ], "intercept": [ "before_tool", "approve_tool" ], "env": { "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" } } } } } ``` 观察方式: ```bash tail -f /tmp/picoclaw-hook-review-gate.log ``` 如果你是在开发 PicoClaw 本体,而不是只想验证协议,那么再看后面的 Go in-process 示例。 ## 两个示例的定位 - Go in-process 示例 适合验证宿主内的 hook 链路、理解 `MountHook()` 和各个同步点位 - Python process 示例 适合理解 `JSON-RPC over stdio` 协议、确认宿主和外部进程之间的消息来回是否正常 这两个示例都刻意保持为“只记录、不改写、不拒绝”的安全模式。它们的目的不是提供策略能力,而是帮你观察当前 hook 系统。 ## Go 进程内示例 下面这段代码是一个最小的“记录型” in-process hook。它实现了: 1. `EventObserver` 2. `LLMInterceptor` 3. `ToolInterceptor` 4. `ToolApprover` 它只记录,不改写请求,也不拒绝工具。 你可以把它保存成你自己的 Go 文件,例如 `pkg/myhooks/example_logger.go`: ```go package myhooks import ( "context" "encoding/json" "os" "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/logger" ) type ExampleLoggerHookOptions struct { LogFile string `json:"log_file,omitempty"` LogEvents bool `json:"log_events,omitempty"` } type ExampleLoggerHook struct { logFile string logEvents bool mu sync.Mutex } func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { return &ExampleLoggerHook{ logFile: strings.TrimSpace(opts.LogFile), logEvents: opts.LogEvents, } } func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { _ = ctx if h == nil || !h.logEvents { return nil } h.record("event", evt.Meta, map[string]any{ "event": evt.Kind.String(), "payload": evt.Payload, }, nil) return nil } func (h *ExampleLoggerHook) BeforeLLM( ctx context.Context, req *agent.LLMHookRequest, ) (*agent.LLMHookRequest, agent.HookDecision, error) { _ = ctx h.record("before_llm", req.Meta, req, agent.HookDecision{Action: agent.HookActionContinue}) return req, agent.HookDecision{Action: agent.HookActionContinue}, nil } func (h *ExampleLoggerHook) AfterLLM( ctx context.Context, resp *agent.LLMHookResponse, ) (*agent.LLMHookResponse, agent.HookDecision, error) { _ = ctx h.record("after_llm", resp.Meta, resp, agent.HookDecision{Action: agent.HookActionContinue}) return resp, agent.HookDecision{Action: agent.HookActionContinue}, nil } func (h *ExampleLoggerHook) BeforeTool( ctx context.Context, call *agent.ToolCallHookRequest, ) (*agent.ToolCallHookRequest, agent.HookDecision, error) { _ = ctx h.record("before_tool", call.Meta, call, agent.HookDecision{Action: agent.HookActionContinue}) return call, agent.HookDecision{Action: agent.HookActionContinue}, nil } func (h *ExampleLoggerHook) AfterTool( ctx context.Context, result *agent.ToolResultHookResponse, ) (*agent.ToolResultHookResponse, agent.HookDecision, error) { _ = ctx h.record("after_tool", result.Meta, result, agent.HookDecision{Action: agent.HookActionContinue}) return result, agent.HookDecision{Action: agent.HookActionContinue}, nil } func (h *ExampleLoggerHook) ApproveTool( ctx context.Context, req *agent.ToolApprovalRequest, ) (agent.ApprovalDecision, error) { _ = ctx decision := agent.ApprovalDecision{Approved: true} h.record("approve_tool", req.Meta, req, decision) return decision, nil } func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { logger.InfoCF("hooks", "Example hook observed", map[string]any{ "stage": stage, }) if h == nil || h.logFile == "" { return } entry := map[string]any{ "ts": time.Now().UTC(), "stage": stage, "meta": meta, "payload": payload, "decision": decision, } body, err := json.Marshal(entry) if err != nil { logger.WarnCF("hooks", "Example hook log encode failed", map[string]any{ "stage": stage, "error": err.Error(), }) return } h.mu.Lock() defer h.mu.Unlock() if dir := filepath.Dir(h.logFile); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { logger.WarnCF("hooks", "Example hook log mkdir failed", map[string]any{ "stage": stage, "path": h.logFile, "error": err.Error(), }) return } } file, err := os.OpenFile(h.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { logger.WarnCF("hooks", "Example hook log open failed", map[string]any{ "stage": stage, "path": h.logFile, "error": err.Error(), }) return } defer func() { _ = file.Close() }() if _, err := file.Write(append(body, '\n')); err != nil { logger.WarnCF("hooks", "Example hook log write failed", map[string]any{ "stage": stage, "path": h.logFile, "error": err.Error(), }) } } ``` ### 如何挂载 如果你只需要代码挂载,直接在 `AgentLoop` 初始化后调用: ```go hook := myhooks.NewExampleLoggerHook(myhooks.ExampleLoggerHookOptions{ LogFile: "/tmp/picoclaw-hook-example-logger.log", LogEvents: true, }) if err := al.MountHook(agent.NamedHook("example-logger", hook)); err != nil { panic(err) } ``` ### 如果你还想用配置挂载 当前 hook 系统支持 builtin hook,但这要求你自己把 factory 编进二进制。也就是说,下面这段注册代码需要和上面的 hook 定义一起放进你的工程里: ```go package myhooks import ( "context" "encoding/json" "fmt" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/config" ) func init() { if err := agent.RegisterBuiltinHook("example_logger", func( ctx context.Context, spec config.BuiltinHookConfig, ) (any, error) { _ = ctx var opts ExampleLoggerHookOptions if len(spec.Config) > 0 { if err := json.Unmarshal(spec.Config, &opts); err != nil { return nil, fmt.Errorf("decode example_logger config: %w", err) } } return NewExampleLoggerHook(opts), nil }); err != nil { panic(err) } } ``` 只有在你自己注册了 builtin 之后,下面的配置才会生效: ```json { "hooks": { "enabled": true, "builtins": { "example_logger": { "enabled": true, "priority": 10, "config": { "log_file": "/tmp/picoclaw-hook-example-logger.log", "log_events": true } } } } } ``` ### 如何观察它是否生效 - 如果设置了 `log_file`,它会把每次 hook 调用按 JSON Lines 写入文件 - 如果没有设置 `log_file`,它仍然会把摘要写到 gateway 日志 - 普通只走 LLM 的请求,通常会看到 `before_llm` 和 `after_llm` - 触发工具调用的请求,通常还会看到 `before_tool`、`approve_tool`、`after_tool` - 如果 `log_events=true`,还会额外看到 `event` 典型日志: ```json {"ts":"2026-03-21T14:10:00Z","stage":"before_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"action":"continue"}} {"ts":"2026-03-21T14:10:00Z","stage":"approve_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"approved":true}} ``` 如果你只看到了 `before_llm` / `after_llm`,没有看到 tool 相关阶段,通常不是 hook 没挂上,而是这次请求本身没有触发工具调用。 ## Python process hook 示例 下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持: 1. `hook.hello` 2. `hook.event` 3. `hook.before_tool` 4. `hook.approve_tool` 它默认只记录,不改写,也不拒绝。 你可以把它保存到任意本地路径,例如 `/tmp/review_gate.py`: ```python #!/usr/bin/env python3 from __future__ import annotations import json import os import signal import sys from datetime import datetime, timezone from typing import Any LOG_EVENTS = os.getenv("PICOCLAW_HOOK_LOG_EVENTS", "1").lower() not in {"0", "false", "no"} LOG_FILE = os.getenv("PICOCLAW_HOOK_LOG_FILE", "").strip() def append_log(entry: dict[str, Any]) -> None: if not LOG_FILE: return payload = { "ts": datetime.now(timezone.utc).isoformat(), **entry, } try: log_dir = os.path.dirname(LOG_FILE) if log_dir: os.makedirs(log_dir, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as handle: handle.write(json.dumps(payload, ensure_ascii=True) + "\n") except OSError as exc: log_stderr(f"failed to write hook log file {LOG_FILE}: {exc}") def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None: payload: dict[str, Any] = { "jsonrpc": "2.0", "id": message_id, } if error is not None: payload["error"] = {"code": -32000, "message": error} else: payload["result"] = result if result is not None else {} append_log({ "direction": "out", "id": message_id, "response": payload.get("result"), "error": payload.get("error"), }) try: sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n") sys.stdout.flush() except BrokenPipeError: raise SystemExit(0) from None def log_stderr(message: str) -> None: try: sys.stderr.write(message + "\n") sys.stderr.flush() except BrokenPipeError: raise SystemExit(0) from None def handle_shutdown_signal(signum: int, _frame: Any) -> None: raise KeyboardInterrupt(f"received signal {signum}") def handle_before_tool(params: dict[str, Any]) -> dict[str, Any]: _ = params return {"action": "continue"} def handle_approve_tool(params: dict[str, Any]) -> dict[str, Any]: _ = params return {"approved": True} def handle_request(method: str, params: dict[str, Any]) -> dict[str, Any]: if method == "hook.hello": return {"ok": True, "name": "python-review-gate"} if method == "hook.before_tool": return handle_before_tool(params) if method == "hook.approve_tool": return handle_approve_tool(params) if method == "hook.before_llm": return {"action": "continue"} if method == "hook.after_llm": return {"action": "continue"} if method == "hook.after_tool": return {"action": "continue"} raise KeyError(f"method not found: {method}") def main() -> int: try: for raw_line in sys.stdin: line = raw_line.strip() if not line: continue try: message = json.loads(line) except json.JSONDecodeError as exc: log_stderr(f"failed to decode request: {exc}") append_log({ "direction": "in", "decode_error": str(exc), "raw": line, }) continue method = message.get("method") message_id = message.get("id", 0) params = message.get("params") or {} if not isinstance(params, dict): params = {} append_log({ "direction": "in", "id": message_id, "method": method, "params": params, "notification": not bool(message_id), }) if not message_id: if method == "hook.event" and LOG_EVENTS: log_stderr(f"observed event: {params.get('Kind')}") continue try: result = handle_request(str(method or ""), params) except KeyError as exc: send_response(int(message_id), error=str(exc)) continue except Exception as exc: send_response(int(message_id), error=f"unexpected error: {exc}") continue send_response(int(message_id), result=result) except KeyboardInterrupt: return 0 return 0 if __name__ == "__main__": signal.signal(signal.SIGINT, handle_shutdown_signal) signal.signal(signal.SIGTERM, handle_shutdown_signal) raise SystemExit(main()) ``` ### 如何配置 ```json { "hooks": { "enabled": true, "processes": { "py_review_gate": { "enabled": true, "priority": 100, "transport": "stdio", "command": [ "python3", "/abs/path/to/review_gate.py" ], "observe": [ "tool_exec_start", "tool_exec_end", "tool_exec_skipped" ], "intercept": [ "before_tool", "approve_tool" ], "env": { "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" } } } } } ``` ### 环境变量 - `PICOCLAW_HOOK_LOG_EVENTS` 是否把 `hook.event` 写到 `stderr`,默认开启 - `PICOCLAW_HOOK_LOG_FILE` 外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件 注意:`PICOCLAW_HOOK_LOG_FILE` 没有默认值。不设置时,脚本不会自动落盘日志。 ### 如何确认它收到了 hook 推荐同时看两个地方: - gateway 日志 用来观察宿主是否成功启动了外部进程,以及脚本写到 `stderr` 的事件摘要 - `PICOCLAW_HOOK_LOG_FILE` 用来观察脚本实际收到了什么请求、返回了什么响应 典型判断方式: - 只看到 `hook.hello` 说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来 - 看到 `hook.event` 说明 `observe` 配置生效了 - 看到 `hook.before_tool` 说明 `intercept: ["before_tool", ...]` 生效了 - 看到 `hook.approve_tool` 说明审批 hook 生效了 这份示例脚本不会改写任何参数,也不会拒绝工具,所以你应该看到的典型返回是: ```json {"direction":"out","id":7,"response":{"action":"continue"},"error":null} {"direction":"out","id":8,"response":{"approved":true},"error":null} ``` 一组完整样例: ```json {"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} {"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} {"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} ``` 补充说明: - 时间戳是 UTC,不是本地时区 - `notification=true` 表示这是 `hook.event` 这类不需要响应的通知 - `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始 ## Process Hook 协议约定 当前 process hook 使用 `JSON-RPC over stdio`: - PicoClaw 启动外部进程 - 请求和响应都按“一行一个 JSON 消息”传输 - `hook.event` 是 notification,不需要响应 - `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response 当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。 ## 配置字段 ### `hooks.builtins.` - `enabled` - `priority` - `config` ### `hooks.processes.` - `enabled` - `priority` - `transport` 当前只支持 `stdio` - `command` - `dir` - `env` - `observe` - `intercept` ## 排查建议 当你觉得“hook 没触发”时,优先按这个顺序排查: 1. `hooks.enabled` 是否为 `true` 2. 对应的 builtin/process hook 是否 `enabled` 3. process hook 的 `command` 路径是否正确 4. 你看的是否是正确的日志文件 5. 当前请求是否真的走到了对应阶段 6. `observe` / `intercept` 是否包含了你想看的点位 一个很实用的最小排查组合是: - 先用文档里的 Python process 示例确认外部协议没问题 - 再用文档里的 Go in-process 示例确认宿主内的 hook 链路没问题 如果前者有 `hook.hello` 但没有业务请求,通常不是协议挂了,而是当前这次请求没有真正触发对应的 hook 点位。 ## 适用边界 当前 hook 系统最适合做这些事: - LLM 请求改写 - 工具参数规范化 - 工具执行前审批 - 审计和观测 当前还不适合直接承载这些需求: - 外部 hook 主动发 channel 消息 - 挂起 turn 并等待人工审批回复 - inbound/outbound 全链路消息拦截 如果你要做人审流转,推荐把 hook 作为审批入口,把审批状态机和 channel 交互放到独立的 `ApprovalManager`。