mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
477 lines
11 KiB
Markdown
477 lines
11 KiB
Markdown
# 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` 定义了稳定的 `EventKind`、`EventMeta` 和 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` 实现一致,不需要推翻已有事件系统
|
||
- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求
|