Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

Issue #98:工具执行 Hook 机制(PreToolUse / PostToolUse)

调研结果

1. 工具定义与执行流程

核心类型定义

类型文件说明
Tool 接口tools/interface.go:82-84Execute(ctx *ToolContext, input string) (*ToolResult, error)
ToolResulttools/interface.go:66-74工具执行结果,含 Summary/Detail/Tips/IsError
ToolContexttools/interface.go:19-55工具执行上下文,含 Ctx/WorkingDir/Registry 等
Registrytools/interface.go:86-90工具注册表,支持全局/核心/会话级工具管理
ToolCallllm/types.go:57-61LLM 层的工具调用结构,含 ID/Name/Arguments

工具执行调用链

工具执行有两条路径,最终都调用 Tool.Execute()

路径 A:主 Agent 工具执行器agent/engine_wire.go:391-455

Agent.buildToolExecutor()
  → 闭包函数(ctx, tc llm.ToolCall)
    → session MCP 查找 / 全局 Registry 查找
    → 激活检查 (IsToolActive)
    → 刷新 round (TouchTool)
    → buildToolContext(ctx, cfg)
    → tool.Execute(toolCtx, tc.Arguments)  ← 【插入点 A】

路径 B:SubAgent 默认执行器agent/engine.go:686-695

defaultToolExecutor(cfg)
  → 闭包函数(ctx, tc)
    → cfg.Tools.Get(tc.Name)
    → buildToolContext(ctx, cfg)
    → tool.Execute(toolCtx, tc.Arguments)  ← 【插入点 B】

统一调用入口:两条路径都通过 RunConfig.ToolExecutor 字段注入到 engine.goRun() 函数:

// agent/engine.go:93
ToolExecutor func(ctx context.Context, tc llm.ToolCall) (*tools.ToolResult, error)

Run() 主循环中,execOne 闭包(engine.go:351-405)负责单个工具的执行:

// engine.go:391-395
start := time.Now()
result, execErr := toolExecutor(execCtx, tc)  // ← 【统一插入点】
elapsed := time.Since(start)

2. Middleware 机制分析

agent/middleware.go 中已实现 MessagePipeline,设计模式清晰:

组件说明
MessageMiddleware 接口Name() + Priority() + Process(mc)
MessagePipeline并发安全的中间件链,支持 Use/Remove/Run
执行模式按 Priority 排序,顺序执行,Process 返回 error 时日志记录但继续
并发安全sync.RWMutex,Run 时获取快照后释放锁

关键设计决策

  • middleware 失败不中断流程(仅日志 Warn)
  • 支持 Use/Remove 动态增删
  • Run 使用 snapshot 避免 Run 期间锁竞争

3. Hook 插入策略分析

选项 1:在 tool.Execute() 内部插入 — 需要修改所有 54 个 Execute() 方法,不可行。

选项 2:在 ToolExecutor 层插入(推荐) — 修改 buildToolExecutordefaultToolExecutor 的返回闭包,在调用 tool.Execute() 前后插入 hook 调用。只需改 2 处,且两条路径都能覆盖。

选项 3:在 Run()execOne 中插入 — 虽然统一,但 hook 需要访问 ToolContext(如 channel/senderID),而 execOne 只能通过闭包获取 toolExecutor

结论:选项 2 最优。hook 需要的信息(toolName、params、result、context)都可以在 ToolExecutor 闭包中获取。


方案

一、ToolHook 接口定义

新建文件 tools/hook.go

package tools

import (
    "context"
    "time"
)

// ToolHook 工具执行钩子接口
// 在工具调用前后提供扩展点,用于日志、审计、拦截、统计等场景。
type ToolHook interface {
    // Name 返回 hook 名称,用于日志和调试
    Name() string

    // PreToolUse 在工具执行前调用。
    // params: 工具参数(已解析的 JSON,map 形式,便于 hook 访问字段)
    // 返回 error 时跳过工具执行,将 error 作为工具执行结果返回给 LLM。
    PreToolUse(ctx context.Context, toolName string, params map[string]any) error

    // PostToolUse 在工具执行后调用(无论 PreToolUse 是否跳过)。
    // result: 工具执行结果(PreToolUse 返回 error 时为 nil)
    // elapsed: 工具执行耗时
    // err: 工具执行错误(PreToolUse 返回 error 时为该 error)
    PostToolUse(ctx context.Context, toolName string, params map[string]any, result *ToolResult, err error, elapsed time.Duration)
}

二、HookChain 管理器

新建文件 tools/hook.go(与接口同文件):

// HookChain 工具 hook 链,按注册顺序执行所有 hook。
// 并发安全:支持运行时动态注册/注销。
type HookChain struct {
    mu    sync.RWMutex
    hooks []ToolHook
}

func NewHookChain() *HookChain { ... }
func (c *HookChain) Use(hooks ...ToolHook) { ... }
func (c *HookChain) Remove(name string) int { ... }
func (c *HookChain) RunPre(ctx context.Context, toolName string, params map[string]any) error { ... }
func (c *HookChain) RunPost(ctx context.Context, toolName string, params map[string]any, result *ToolResult, err error, elapsed time.Duration) { ... }

执行规则

场景PreToolUsePostToolUse
hook 返回 error立即停止 Pre 链,返回该 error,不执行工具仍执行所有 Post hook(err = hook 的 error)
hook panicrecover,记录日志,继续下一个 hook同左
hook 正常继续下一个继续下一个

关键:PreToolUse 返回 error 时阻断工具执行,但 PostToolUse 始终执行(确保审计日志完整)。

三、RunConfig 集成

修改 agent/engine.goRunConfig

type RunConfig struct {
    // ... 现有字段 ...
    
    // HookChain 工具执行 hook 链(nil = 无 hook)
    HookChain *tools.HookChain
}

四、ToolExecutor 闭包插入 Hook

4.1 主 Agent 工具执行器(agent/engine_wire.go:437

buildToolExecutor 返回的闭包中,tool.Execute(toolCtx, tc.Arguments) 调用前后插入:

return func(ctx context.Context, tc llm.ToolCall) (*tools.ToolResult, error) {
    // ... 现有的 1-4 步(查找、激活检查、刷新 round、mkdir) ...
    
    // 5. 解析参数(供 hook 使用)
    var params map[string]any
    json.Unmarshal([]byte(tc.Arguments), &params)
    
    // 6. 执行 PreToolUse hooks
    if cfg.HookChain != nil {
        if err := cfg.HookChain.RunPre(ctx, tc.Name, params); err != nil {
            return nil, err  // 跳过工具执行
        }
    }
    
    // 7. 执行工具
    toolCtx := buildToolContext(ctx, cfg)
    start := time.Now()
    result, execErr := tool.Execute(toolCtx, tc.Arguments)
    elapsed := time.Since(start)
    
    // 8. 执行 PostToolUse hooks
    if cfg.HookChain != nil {
        cfg.HookChain.RunPost(ctx, tc.Name, params, result, execErr, elapsed)
    }
    
    return result, execErr
}

注意buildToolExecutor 的闭包需要能访问 cfg.HookChain。当前闭包内部定义了 cfg *RunConfig,只需在闭包内增加 hook 调用即可。

4.2 SubAgent 默认执行器(agent/engine.go:686

同理在 defaultToolExecutor 的闭包中插入 hook:

func defaultToolExecutor(cfg *RunConfig) func(ctx context.Context, tc llm.ToolCall) (*tools.ToolResult, error) {
    return func(ctx context.Context, tc llm.ToolCall) (*tools.ToolResult, error) {
        tool, ok := cfg.Tools.Get(tc.Name)
        if !ok {
            return nil, fmt.Errorf("unknown tool: %s", tc.Name)
        }

        // 解析参数
        var params map[string]any
        json.Unmarshal([]byte(tc.Arguments), &params)

        // Pre hooks
        if cfg.HookChain != nil {
            if err := cfg.HookChain.RunPre(ctx, tc.Name, params); err != nil {
                return nil, err
            }
        }

        toolCtx := buildToolContext(ctx, cfg)
        start := time.Now()
        result, execErr := tool.Execute(toolCtx, tc.Arguments)
        elapsed := time.Since(start)

        // Post hooks
        if cfg.HookChain != nil {
            cfg.HookChain.RunPost(ctx, tc.Name, params, result, execErr, elapsed)
        }

        return result, execErr
    }
}

4.3 RunConfig 传递 HookChain

buildMainRunConfigagent/engine_wire.go:40-140)、buildCronRunConfigagent/engine_wire.go:143-200)、buildSubAgentRunConfigagent/engine_wire.go:203-350)中,将 Agent.hookChain 传入 RunConfig.HookChain

五、内置 Hook 实现

新建文件 tools/hook_builtin.go

5.1 日志 Hook(LoggingHook)

type LoggingHook struct{}

func (h *LoggingHook) Name() string { return "logging" }

func (h *LoggingHook) PreToolUse(ctx context.Context, toolName string, params map[string]any) error {
    log.Ctx(ctx).WithFields(log.Fields{
        "tool":   toolName,
        "params": params,
    }).Info("Tool execution started")
    return nil
}

func (h *LoggingHook) PostToolUse(ctx context.Context, toolName string, params map[string]any, result *ToolResult, err error, elapsed time.Duration) {
    fields := log.Fields{
        "tool":    toolName,
        "elapsed": elapsed.Round(time.Millisecond),
    }
    if err != nil {
        log.Ctx(ctx).WithFields(fields).WithError(err).Warn("Tool execution failed")
    } else {
        log.Ctx(ctx).WithFields(fields).Info("Tool execution completed")
    }
}

5.2 耗时统计 Hook(TimingHook)

type TimingHook struct {
    mu       sync.Mutex
    stats    map[string]*toolTimingStats  // toolName → stats
}

type toolTimingStats struct {
    Count    int
    Total    time.Duration
    Min      time.Duration
    Max      time.Duration
}

func NewTimingHook() *TimingHook { ... }

func (h *TimingHook) Name() string { return "timing" }

func (h *TimingHook) PreToolUse(ctx context.Context, toolName string, params map[string]any) error {
    return nil  // 仅在 Post 中统计
}

func (h *TimingHook) PostToolUse(ctx context.Context, toolName string, params map[string]any, result *ToolResult, err error, elapsed time.Duration) {
    h.mu.Lock()
    defer h.mu.Unlock()
    // 累加统计
}

// Stats 返回各工具的耗时统计快照
func (h *TimingHook) Stats() map[string]toolTimingStats { ... }

// Reset 清零统计
func (h *TimingHook) Reset() { ... }

六、Agent 集成

修改 agent/agent.go

type Agent struct {
    // ... 现有字段 ...
    hookChain *tools.HookChain  // 工具执行 hook 链
}

New() 函数中初始化默认 hook chain:

// agent/agent.go New() 函数中
hookChain := tools.NewHookChain()
hookChain.Use(tools.NewLoggingHook())
hookChain.Use(tools.NewTimingHook())
agent.hookChain = hookChain

暴露方法供外部注册 hook:

// ToolHookChain 返回 Agent 的工具 hook 链,支持运行时动态注册/注销
func (a *Agent) ToolHookChain() *tools.HookChain {
    return a.hookChain
}

七、配置机制

当前不需要额外的配置文件。hook 失败的处理策略内置在 HookChain 中

策略说明
PreToolUse panicrecover + 日志 Error + 继续下一个 hook(不阻断)
PostToolUse panicrecover + 日志 Error + 继续下一个
PreToolUse 返回 error阻断工具执行,返回该 error

这与 middleware 的策略一致:hook 自身异常不阻断主流程,但 PreToolUse 返回的语义 error 会阻断

如果未来需要更细粒度的配置(如某个 hook 失败是否阻断),可以在 ToolHook 接口增加 OnPanic() error 方法,或通过 HookOption 配置。当前版本保持简洁。


任务拆解

阶段 1:核心接口与管理器(无破坏性改动)

#文件操作说明
1tools/hook.go新建定义 ToolHook 接口、HookChain 结构体及方法
2tools/hook.go新建HookChain.Use() / Remove() / RunPre() / RunPost() 实现

阶段 2:内置 Hook 实现

#文件操作说明
3tools/hook_builtin.go新建LoggingHook 实现
4tools/hook_builtin.go新建TimingHook 实现(含 Stats() / Reset()

阶段 3:Agent 集成

#文件操作说明
5agent/engine.go修改RunConfig 增加 HookChain *tools.HookChain 字段
6agent/engine_wire.go修改buildToolExecutor() 闭包中插入 Pre/Post hook 调用
7agent/engine.go修改defaultToolExecutor() 闭包中插入 Pre/Post hook 调用
8agent/engine_wire.go修改buildMainRunConfig() 传递 HookChain
9agent/engine_wire.go修改buildCronRunConfig() 传递 HookChain
10agent/engine_wire.go修改buildSubAgentRunConfig() 传递 HookChain
11agent/agent.go修改Agent 结构体增加 hookChain 字段
12agent/agent.go修改New() 函数中初始化默认 hook chain
13agent/agent.go修改新增 ToolHookChain() 公开方法

阶段 4:测试

#文件操作说明
14tools/hook_test.go新建HookChain 单元测试
15tools/hook_builtin_test.go新建LoggingHook / TimingHook 单元测试
16agent/engine_test.go修改增加带 hook 的 Run() 测试用例

改动汇总

类型数量
新建文件4(hook.go, hook_builtin.go, hook_test.go, hook_builtin_test.go
修改文件4(engine.go, engine_wire.go, agent.go, engine_test.go
新增代码量~300 行(含测试 ~200 行)

验证标准

阶段 1 验证

  • HookChain 支持注册多个 hook,按注册顺序执行
  • HookChain.Remove() 按名称移除,返回移除数量
  • RunPre() 中任一 hook 返回 error,立即停止并返回该 error
  • RunPre() 中 hook panic 时 recover 并继续下一个
  • RunPost() 始终执行所有 hook(即使 Pre 返回了 error)
  • 并发安全:多个 goroutine 同时 Use/Remove/Run 无 data race

阶段 2 验证

  • LoggingHook 在 Pre/Post 阶段输出正确的日志(通过日志捕获验证)
  • TimingHook 正确累计各工具的 count/total/min/max
  • TimingHook.Stats() 返回正确的快照
  • TimingHook.Reset() 正确清零

阶段 3 验证

  • 主 Agent 执行工具时,hook 被正确调用(通过测试 hook 验证调用顺序和参数)
  • SubAgent 执行工具时,hook 被正确调用
  • PreToolUse 返回 error 时,工具不执行,error 返回给 LLM
  • go test ./... 全部通过
  • go vet ./... 无警告
  • go build ./... 编译成功

阶段 4 验证

  • 测试覆盖:Pre 正常/Pre 阻断/Post 正常/panic 恢复/并发安全
  • go test -race ./tools/ ./agent/ 无 race

风险与注意

1. 参数解析开销

每次工具调用都需要 json.Unmarshal 参数为 map[string]any。对于高频调用的工具(如 Shell),这是额外开销。

缓解:解析失败时传空 map(不阻断),且 JSON 解析对小型参数(<1KB)几乎无感知。

2. Hook 阻断风险

PreToolUse 返回 error 会阻断工具执行。如果用户注册了有 bug 的 hook,可能导致所有工具无法使用。

缓解

  • hook 自身的 panic 不会阻断(recover 后继续)
  • 文档明确说明 PreToolUse 返回 error 的语义
  • 可通过 HookChain.Remove() 快速移除有问题的 hook

3. 与 Middleware 的职责边界

Middleware 管消息构建(system prompt、历史、skills 注入),Hook 管工具执行。两者互补,不重叠。

注意:不要在 Hook 中修改 LLM 消息或上下文,那是 Middleware 的职责。

4. 性能影响

Hook 增加了每次工具调用的固定开销(两次函数调用 + JSON 解析)。

缓解

  • LoggingHookTimingHook 本身非常轻量(~微秒级)
  • 可通过 HookChain.Remove("logging") 在生产环境关闭日志 hook
  • 未来可考虑 hook 执行结果的 metrics 上报

5. SubAgent Hook 继承

当前方案中 SubAgent 继承父 Agent 的 HookChain 实例。这意味着所有层级共享同一套 hook。

这是合理的:统计和日志通常需要全局视角。如果未来需要按 Agent 级别隔离 hook,可通过 buildSubAgentRunConfig 中创建新的 HookChain 来实现。

6. 现有日志的调整

engine.go:388-405 中的 execOne 闭包已有工具调用的日志(Tool call: / Tool done: / Tool failed:)。引入 LoggingHook 后,这些日志会重复。

建议:在阶段 3 实现时,评估是否将 execOne 中的日志移除或降级为 Debug,由 LoggingHook 统一接管。这是可选的优化,不影响功能正确性。