Back to Picoclaw

PicoClaw Hook 系统设计(基于 `refactor/agent`)

docs/design/hook-system-design.zh.md

0.2.811.2 KB
Original Source

PicoClaw Hook 系统设计(基于 refactor/agent

背景

本设计围绕两个议题展开:

  • #1316:把 agent loop 重构为事件驱动、可中断、可追加、可观测
  • #1796:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型

当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。

外部项目对比

OpenClaw

OpenClaw 的扩展能力分成三层:

  • Internal hooks:目录发现,运行在 Gateway 进程内
  • Plugin hooks:插件在运行时注册 hook,也在进程内
  • Webhooks:外部系统通过 HTTP 触发 Gateway 动作,属于进程外

值得借鉴的点:

  • 有“项目内挂载”和“项目外挂载”两种路径
  • hook 是配置驱动,可启停
  • 外部入口有明确的安全边界和映射层

不建议直接照搬的点:

  • OpenClaw 的 hooks / plugin hooks / webhooks 是三套路由,PicoClaw 当前体量下会偏重
  • HTTP webhook 更适合“事件进入系统”,不适合作为“可同步拦截 agent loop”的基础机制

pi-mono

pi-mono 的核心思路更接近当前分支:

  • 扩展统一为 extension API
  • 事件分为观察型和可变更型
  • 某些阶段允许 transform / block / replace
  • 扩展代码主要是进程内执行
  • RPC mode 把 UI 交互桥接到进程外客户端

值得借鉴的点:

  • 不把“观察”和“拦截”混成一个接口
  • 允许返回结构化动作,而不是只有回调
  • 进程外通信只暴露必要协议,不把整个内部对象图泄露出去

当前分支现状

已有能力

当前分支已经具备 hook 系统的地基:

  • pkg/agent/events.go 定义了稳定的 EventKindEventMeta 和 payload
  • pkg/agent/eventbus.go 提供了非阻塞 fan-out 的 EventBus
  • pkg/agent/loop.go 中的 runTurn() 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件
  • pkg/agent/steering.go 已支持 steering、graceful interrupt、hard abort
  • pkg/agent/turn.go 已维护 turn phase、恢复点、active turn、abort 状态

现有缺口

当前分支还缺四件事:

  • 没有 HookManager,只有 EventBus
  • 没有 Before/After LLM、Before/After Tool 这种同步拦截点
  • 没有审批型 hook
  • 子 agent 仍走 pkg/tools/SubagentManager + RunToolLoop,没有接入 pkg/agent 的 turn tree 和事件流

一个关键现实

#1316 文案里提到“只读并行、写入串行”的工具执行策略,但当前 runTurn() 实现已经先收敛成“顺序执行 + 每个工具后检查 steering / interrupt”。因此 hook 设计不应依赖未来的并行模型,而应该先兼容当前顺序执行,再为以后增加 ReadOnlyIndicator 留口子。

设计原则

  • Hook 必须建立在 pkg/agent 的 EventBus 和 turn 上下文之上
  • EventBus 负责广播,HookManager 负责拦截,两者职责分离
  • 项目内挂载要简单,项目外挂载必须走 IPC
  • 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时
  • 先覆盖主 turn,不把 sub-turn 一次做满
  • 不新增第二套用户事件命名系统,优先复用 EventKind.String()

总体架构

分成三层:

  1. EventBus 负责广播只读事件,现有实现直接复用

  2. HookManager 负责管理 hook、排序、超时、错误隔离,并在 runTurn() 的明确检查点执行同步拦截

  3. HookMount 负责两种挂载方式:

    • 进程内 Go hook
    • 进程外 IPC hook

换句话说:

  • EventBus 是“发生了什么”
  • HookManager 是“谁能介入”
  • HookMount 是“这些 hook 从哪里来”

Hook 分类

不建议把所有 hook 都设计成 OnEvent(evt)

建议拆成两类。

1. 观察型

只消费事件,不修改流程:

go
type EventObserver interface {
    OnEvent(ctx context.Context, evt agent.Event) error
}

这类 hook 直接订阅 EventBus 即可。

适用场景:

  • 审计日志
  • 指标上报
  • 调试 trace
  • 将事件转发给外部 UI / TUI / Web 面板

2. 拦截型

只在少数明确节点触发,允许返回动作:

go
type LLMInterceptor interface {
    BeforeLLM(ctx context.Context, req *LLMRequest) HookDecision[*LLMRequest]
    AfterLLM(ctx context.Context, resp *LLMResponse) HookDecision[*LLMResponse]
}

type ToolInterceptor interface {
    BeforeTool(ctx context.Context, call *ToolCall) HookDecision[*ToolCall]
    AfterTool(ctx context.Context, result *ToolResultView) HookDecision[*ToolResultView]
}

type ToolApprover interface {
    ApproveTool(ctx context.Context, req *ToolApprovalRequest) ApprovalDecision
}

这里的 HookDecision 统一支持:

  • continue
  • modify
  • deny_tool
  • abort_turn
  • hard_abort

对外暴露的最小 hook 面

V1 不需要把所有 EventKind 都变成可拦截点。

建议只开放这些同步 hook:

  • before_llm
  • after_llm
  • before_tool
  • after_tool
  • approve_tool

其余节点继续作为只读事件暴露:

  • turn_start
  • turn_end
  • llm_request
  • llm_response
  • tool_exec_start
  • tool_exec_end
  • tool_exec_skipped
  • steering_injected
  • follow_up_queued
  • interrupt_received
  • context_compress
  • session_summarize
  • error

subturn_* 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。

项目内挂载

内部挂载必须尽量低摩擦。

建议提供两种等价方式,底层都走 HookManager。

方式 A:代码显式挂载

go
al.MountHook(hooks.Named("audit", &AuditHook{}))

适用于:

  • 仓内内建 hook
  • 单元测试
  • feature flag 控制

方式 B:内建 registry

go
func init() {
    hooks.RegisterBuiltin("audit", func() hooks.Hook {
        return &AuditHook{}
    })
}

启动时根据配置启用:

json
{
  "hooks": {
    "builtins": {
      "audit": { "enabled": true }
    }
  }
}

这比 OpenClaw 的目录扫描更轻,也更贴合 Go 项目。

项目外挂载

这是本设计的硬要求。

建议 V1 采用:

  • JSON-RPC over stdio

原因:

  • 跨平台最简单
  • 不依赖额外端口
  • 非常适合“由 PicoClaw 启动一个外部 hook 进程”
  • 比 HTTP webhook 更适合同步拦截

外部 hook 进程模型

PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。

配置示例:

json
{
  "hooks": {
    "processes": {
      "review-gate": {
        "enabled": true,
        "transport": "stdio",
        "command": ["uvx", "picoclaw-hook-reviewer"],
        "observe": ["turn_start", "turn_end", "tool_exec_end"],
        "intercept": ["before_tool", "approve_tool"],
        "timeout_ms": 5000
      }
    }
  }
}

协议边界

不要把内部 Go 结构体直接暴露给 IPC。

建议定义稳定的协议对象:

  • HookHandshake
  • HookEventNotification
  • BeforeLLMRequest
  • AfterLLMRequest
  • BeforeToolRequest
  • AfterToolRequest
  • ApproveToolRequest
  • HookDecision

其中:

  • 观察型事件用 notification,fire-and-forget
  • 拦截型事件用 request/response,同步等待

为什么是 stdio,而不是直接用 HTTP webhook

因为两者用途不同:

  • HTTP webhook 更适合“外部系统向 PicoClaw 投递事件”
  • stdio/RPC 更适合“PicoClaw 在 turn 内同步询问外部 hook 是否改写 / 放行 / 拒绝”

如果未来需要 OpenClaw 式 webhook,可以作为独立入口层,再把外部事件转成 inbound message 或 steering,而不是直接替代 hook IPC。

Hook 执行顺序

建议统一排序规则:

  • 先内建 in-process hook
  • 再外部 IPC hook
  • 同组内按 priority 从小到大执行

原因:

  • 内建 hook 延迟更低,适合做基础规范化
  • 外部 hook 更适合做审批、审计、组织级策略

超时与错误策略

观察型

  • 默认超时:500ms
  • 超时或报错:记录日志,继续主流程

拦截型

  • before_llm / after_llm / before_tool / after_tool:默认 5s
  • approve_tool:默认 60s

超时行为:

  • 普通拦截:continue
  • 审批:deny

这点应直接沿用 #1316 的安全倾向。

与当前分支的对接点

直接复用

  • 事件定义:pkg/agent/events.go
  • 事件广播:pkg/agent/eventbus.go
  • 活跃 turn / interrupt / rollback:pkg/agent/turn.go
  • 事件发射点:pkg/agent/loop.go

需要新增

  • pkg/agent/hooks.go

    • Hook 接口
    • HookDecision / ApprovalDecision
    • HookManager
  • pkg/agent/hook_mount.go

    • 内建 hook 注册
    • 外部进程 hook 注册
  • pkg/agent/hook_ipc.go

    • stdio JSON-RPC bridge
  • pkg/agent/hook_types.go

    • IPC 稳定载荷

需要改造

  • pkg/agent/loop.go

    • 在 LLM 和 tool 关键路径前后插入 HookManager 调用
  • pkg/tools/base.go

    • 可选新增 ReadOnlyIndicator
  • pkg/tools/spawn.go

  • pkg/tools/subagent.go

    • 先保留现状
    • 等 sub-turn 迁移后再接入 subturn_* hook

一个更贴合当前分支的数据流

观察链路

text
runTurn() -> emitEvent() -> EventBus -> observers

拦截链路

text
runTurn()
  -> HookManager.BeforeLLM()
  -> Provider.Chat()
  -> HookManager.AfterLLM()
  -> HookManager.BeforeTool()
  -> HookManager.ApproveTool()
  -> tool.Execute()
  -> HookManager.AfterTool()

也就是说:

  • observer 不改变现有 emitEvent()
  • interceptor 直接插在 runTurn() 热路径

用户可见配置

建议新增:

json
{
  "hooks": {
    "enabled": true,
    "builtins": {},
    "processes": {},
    "defaults": {
      "observer_timeout_ms": 500,
      "interceptor_timeout_ms": 5000,
      "approval_timeout_ms": 60000
    }
  }
}

V1 不做复杂自动发现。

原因:

  • 当前分支重点是把地基打稳
  • 目录扫描、安装器、脚手架可以后置
  • 先让仓内和仓外都能挂上去,比“管理体验完整”更重要

推荐的 V1 范围

必做

  • HookManager
  • in-process 挂载
  • stdio IPC 挂载
  • observer hooks
  • before_tool / after_tool / approve_tool
  • before_llm / after_llm

可后置

  • hook CLI 管理命令
  • hook 自动发现
  • Unix socket / named pipe transport
  • sub-turn hook 生命周期
  • read-only 并行分组
  • webhook 到 inbound message 的映射入口

分阶段落地

Phase 1

  • 引入 HookManager
  • 支持 in-process observer + interceptor
  • 先只接主 turn

Phase 2

  • 引入 stdio 外部 hook 进程桥
  • 支持组织级审批 / 审计 / 参数改写

Phase 3

  • SubagentManager 迁移到 runTurn/sub-turn
  • 接通 subturn_spawn / subturn_end / subturn_result_delivered

Phase 4

  • 视需求补 ReadOnlyIndicator
  • 在主 turn 和 sub-turn 上统一只读并行策略

最终结论

最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是:

  • 以现有 EventBus 为只读观察面
  • 以新增 HookManager 为同步拦截面
  • 项目内通过 Go 对象直接挂载
  • 项目外通过 stdio JSON-RPC 进程通信挂载

这样做有三个好处:

  • #1796 一致,hooks 只是 EventBus 之上的消费层
  • 和当前 refactor/agent 实现一致,不需要推翻已有事件系统
  • 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求