| 类型 | 文件 | 说明 |
|---|---|---|
Tool 接口 | tools/interface.go:82-84 | Execute(ctx *ToolContext, input string) (*ToolResult, error) |
ToolResult | tools/interface.go:66-74 | 工具执行结果,含 Summary/Detail/Tips/IsError |
ToolContext | tools/interface.go:19-55 | 工具执行上下文,含 Ctx/WorkingDir/Registry 等 |
Registry | tools/interface.go:86-90 | 工具注册表,支持全局/核心/会话级工具管理 |
ToolCall | llm/types.go:57-61 | LLM 层的工具调用结构,含 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.go 的 Run() 函数:
// 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)
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 期间锁竞争
选项 1:在 tool.Execute() 内部插入 — 需要修改所有 54 个 Execute() 方法,不可行。
选项 2:在 ToolExecutor 层插入(推荐) — 修改 buildToolExecutor 和 defaultToolExecutor 的返回闭包,在调用 tool.Execute() 前后插入 hook 调用。只需改 2 处,且两条路径都能覆盖。
选项 3:在 Run() 的 execOne 中插入 — 虽然统一,但 hook 需要访问 ToolContext(如 channel/senderID),而 execOne 只能通过闭包获取 toolExecutor。
结论:选项 2 最优。hook 需要的信息(toolName、params、result、context)都可以在 ToolExecutor 闭包中获取。
新建文件 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)
}
新建文件 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) { ... }
执行规则:
| 场景 | PreToolUse | PostToolUse |
|---|---|---|
| hook 返回 error | 立即停止 Pre 链,返回该 error,不执行工具 | 仍执行所有 Post hook(err = hook 的 error) |
| hook panic | recover,记录日志,继续下一个 hook | 同左 |
| hook 正常 | 继续下一个 | 继续下一个 |
关键:PreToolUse 返回 error 时阻断工具执行,但 PostToolUse 始终执行(确保审计日志完整)。
修改 agent/engine.go 的 RunConfig:
type RunConfig struct {
// ... 现有字段 ...
// HookChain 工具执行 hook 链(nil = 无 hook)
HookChain *tools.HookChain
}
在 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), ¶ms)
// 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 调用即可。
同理在 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), ¶ms)
// 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
}
}
在 buildMainRunConfig(agent/engine_wire.go:40-140)、buildCronRunConfig(agent/engine_wire.go:143-200)、buildSubAgentRunConfig(agent/engine_wire.go:203-350)中,将 Agent.hookChain 传入 RunConfig.HookChain。
新建文件 tools/hook_builtin.go:
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")
}
}
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.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 panic | recover + 日志 Error + 继续下一个 hook(不阻断) |
| PostToolUse panic | recover + 日志 Error + 继续下一个 |
| PreToolUse 返回 error | 阻断工具执行,返回该 error |
这与 middleware 的策略一致:hook 自身异常不阻断主流程,但 PreToolUse 返回的语义 error 会阻断。
如果未来需要更细粒度的配置(如某个 hook 失败是否阻断),可以在 ToolHook 接口增加 OnPanic() error 方法,或通过 HookOption 配置。当前版本保持简洁。
| # | 文件 | 操作 | 说明 |
|---|---|---|---|
| 1 | tools/hook.go | 新建 | 定义 ToolHook 接口、HookChain 结构体及方法 |
| 2 | tools/hook.go | 新建 | HookChain.Use() / Remove() / RunPre() / RunPost() 实现 |
| # | 文件 | 操作 | 说明 |
|---|---|---|---|
| 3 | tools/hook_builtin.go | 新建 | LoggingHook 实现 |
| 4 | tools/hook_builtin.go | 新建 | TimingHook 实现(含 Stats() / Reset()) |
| # | 文件 | 操作 | 说明 |
|---|---|---|---|
| 5 | agent/engine.go | 修改 | RunConfig 增加 HookChain *tools.HookChain 字段 |
| 6 | agent/engine_wire.go | 修改 | buildToolExecutor() 闭包中插入 Pre/Post hook 调用 |
| 7 | agent/engine.go | 修改 | defaultToolExecutor() 闭包中插入 Pre/Post hook 调用 |
| 8 | agent/engine_wire.go | 修改 | buildMainRunConfig() 传递 HookChain |
| 9 | agent/engine_wire.go | 修改 | buildCronRunConfig() 传递 HookChain |
| 10 | agent/engine_wire.go | 修改 | buildSubAgentRunConfig() 传递 HookChain |
| 11 | agent/agent.go | 修改 | Agent 结构体增加 hookChain 字段 |
| 12 | agent/agent.go | 修改 | New() 函数中初始化默认 hook chain |
| 13 | agent/agent.go | 修改 | 新增 ToolHookChain() 公开方法 |
| # | 文件 | 操作 | 说明 |
|---|---|---|---|
| 14 | tools/hook_test.go | 新建 | HookChain 单元测试 |
| 15 | tools/hook_builtin_test.go | 新建 | LoggingHook / TimingHook 单元测试 |
| 16 | agent/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 行) |
-
HookChain支持注册多个 hook,按注册顺序执行 -
HookChain.Remove()按名称移除,返回移除数量 -
RunPre()中任一 hook 返回 error,立即停止并返回该 error -
RunPre()中 hook panic 时 recover 并继续下一个 -
RunPost()始终执行所有 hook(即使 Pre 返回了 error) - 并发安全:多个 goroutine 同时 Use/Remove/Run 无 data race
-
LoggingHook在 Pre/Post 阶段输出正确的日志(通过日志捕获验证) -
TimingHook正确累计各工具的 count/total/min/max -
TimingHook.Stats()返回正确的快照 -
TimingHook.Reset()正确清零
- 主 Agent 执行工具时,hook 被正确调用(通过测试 hook 验证调用顺序和参数)
- SubAgent 执行工具时,hook 被正确调用
- PreToolUse 返回 error 时,工具不执行,error 返回给 LLM
-
go test ./...全部通过 -
go vet ./...无警告 -
go build ./...编译成功
- 测试覆盖:Pre 正常/Pre 阻断/Post 正常/panic 恢复/并发安全
-
go test -race ./tools/ ./agent/无 race
每次工具调用都需要 json.Unmarshal 参数为 map[string]any。对于高频调用的工具(如 Shell),这是额外开销。
缓解:解析失败时传空 map(不阻断),且 JSON 解析对小型参数(<1KB)几乎无感知。
PreToolUse 返回 error 会阻断工具执行。如果用户注册了有 bug 的 hook,可能导致所有工具无法使用。
缓解:
- hook 自身的 panic 不会阻断(recover 后继续)
- 文档明确说明 PreToolUse 返回 error 的语义
- 可通过
HookChain.Remove()快速移除有问题的 hook
Middleware 管消息构建(system prompt、历史、skills 注入),Hook 管工具执行。两者互补,不重叠。
注意:不要在 Hook 中修改 LLM 消息或上下文,那是 Middleware 的职责。
Hook 增加了每次工具调用的固定开销(两次函数调用 + JSON 解析)。
缓解:
LoggingHook和TimingHook本身非常轻量(~微秒级)- 可通过
HookChain.Remove("logging")在生产环境关闭日志 hook - 未来可考虑 hook 执行结果的 metrics 上报
当前方案中 SubAgent 继承父 Agent 的 HookChain 实例。这意味着所有层级共享同一套 hook。
这是合理的:统计和日志通常需要全局视角。如果未来需要按 Agent 级别隔离 hook,可通过 buildSubAgentRunConfig 中创建新的 HookChain 来实现。
engine.go:388-405 中的 execOne 闭包已有工具调用的日志(Tool call: / Tool done: / Tool failed:)。引入 LoggingHook 后,这些日志会重复。
建议:在阶段 3 实现时,评估是否将 execOne 中的日志移除或降级为 Debug,由 LoggingHook 统一接管。这是可选的优化,不影响功能正确性。