目标
Index 03 让智能体学会了"先问一声"——通过 Permission 管线决定放行/拒绝。但工具执行的链路上还缺一个环节:非功能性扩展。用户可能想在每次工具执行前打日志、测耗时,或在执行后修改结果。
Index 04 的目标是给工具执行链路装上前后两个扩展点:
- 定义
HookEvent统一事件模型,携带事件类型、工具调用、执行结果 - 定义
HookResult三态返回值——Continue(放行)/ Block(阻止,仅 Pre)/ Replace(替换结果,仅 Post) - 实现
HookManager观察者模式——按事件类型注册多个 handler,顺序执行,fail-open 异常处理 - 实现两个内置 Hook——LoggingHook(无状态)和 TimingHook(有状态,演示 Pre/Post 配对)
- 通过回调注入 AgentLoop,agent 包零依赖 hooks 包,复刻 s03 的松耦合做法
完成后的效果——智能体每次工具调用都会留下痕迹:
[Hook] PreToolUse: read_file (id=toolu_01)
[Hook] Tool 'read_file' executed in 3ms
[Hook] PreToolUse: bash (id=toolu_02)
[Permission] Allow? (y/n/always) y
[Hook] PostToolUse: bash读文件自动计时,bash 命令仍要先过权限审批——Hooks 在 Permission 之后介入。
架构:可插拔的观察者管线
Index 04 在 Index 03 的工具执行链路上又插入两个可选的扩展点:
AgentLoop.executeOneTool(toolCall)
│
┌──────────────────────────────────────▼──────────────────────────────────┐
│ hooks.onBeforeToolExecute? ──null──→ 直接进入 Pre Hook │
│ │ (s03 Permission) │
│ ▼ │
│ false ────────────────────────→ 返回 Permission denied │
│ true │
│ ▼ │
│ hooks.onPreToolUse? ───null───→ 直接执行工具 │
│ │ (s04 PreHook) │
│ ▼ │
│ reason? ───────────────────────→ 返回 Blocked by hook │
│ null │
│ ▼ │
│ ③ ToolRegistry.get(name).execute(input) │
│ │ │
│ ▼ │
│ ToolResult (含异常包装) │
│ │ │
│ ▼ │
│ hooks.onPostToolUse? ──null──→ 直接返回原 result │
│ │ (s04 PostHook) │
│ ▼ │
│ 可被 Replace 替换的 ToolResult → 回传 LLM │
└────────────────────────────────────────────────────────────────────────┘所有可插拔回调收纳在 AgentLoopHooks 数据类中,由 ReplLoop 组装后注入。
| 层 | Index 03 状态 | Index 04 变更 |
|---|---|---|
| 事件模型 | 无 | HookEvent(data class + Type 枚举) |
| 返回值 | 无 | HookResult(sealed class: Continue/Block/Replace) |
| 分发器 | 无 | HookManager(按 Type 分组 + 顺序执行 + fail-open) |
| 内置实现 | 无 | LoggingHook + TimingHook |
| 智能体 | 1 个 onBeforeToolExecute 回调 | 引入 AgentLoopHooks 收纳全部回调 |
| REPL | 组装 PermissionPipeline | +构建 HookManager 并通过 hooks 注入 |
第一层:事件与返回值
HookEvent:单个 data class + Type 枚举
data class HookEvent(
val type: HookEvent.Type,
val toolCall: ToolCall,
val result: ToolResult? = null // Pre 时 null;Post 时非空
) {
enum class Type { PRE_TOOL_USE, POST_TOOL_USE }
}为什么不用 sealed interface 分裂为 PreEvent/PostEvent?
考虑过两种方案:
// 方案 A(采用):单个 HookEvent,用 result 是否为 null 区分
data class HookEvent(val type: Type, val toolCall: ToolCall, val result: ToolResult? = null)
// 方案 B(弃用):sealed interface 分裂
sealed interface HookEvent {
data class Pre(val toolCall: ToolCall) : HookEvent
data class Post(val toolCall: ToolCall, val result: ToolResult) : HookEvent
}选 A 的理由:
- 与设计文档
suspend (HookEvent) -> HookResult签名一致 - 字段重叠度高(都需要
toolCall),分裂会重复 result: ToolResult?已足够区分 Pre/Post,语义清晰
HookResult:sealed class 三态
sealed class HookResult {
object Continue : HookResult() // 放行
data class Block(val reason: String) : HookResult() // Pre 阻止
data class Replace(val result: ToolResult) : HookResult() // Post 替换
}为什么用 sealed class 而不是 data class?
// 弃用方案:单 data class
data class HookResult(val block: String? = null, val replace: ToolResult? = null)弃用理由:存在 block != null && replace != null 的非法状态。sealed class 让三种结果互斥,when 表达式有穷尽性检查,编译期保证所有分支都被处理。
类型不匹配怎么办? PreToolUse handler 返回 Replace(语义不合法)→ HookManager 记 warn 日志并视为 Continue;PostToolUse handler 返回 Block → 同样处理。不抛异常——hook 是非功能性扩展,不应该让一个手抖写错类型的 hook 拖垮工具执行。
第二层:HookManager — 注册-分发-聚合
class HookManager {
private val handlers: MutableMap<HookEvent.Type, MutableList<HookHandler>> = mutableMapOf()
fun on(event: HookEvent.Type, handler: HookHandler) { ... }
suspend fun fire(event: HookEvent): List<HookResult> {
// 顺序执行所有 handler,异常视为 Continue
}
// 便捷方法:返回原语供 AgentLoop 使用,避免 agent 包 import hooks 包
suspend fun firePreToolUse(toolCall: ToolCall): String? // null=放行
suspend fun firePostToolUse(toolCall: ToolCall, result: ToolResult): ToolResult
}三个关键设计决策:
1. 顺序执行,不并发
hook 有副作用(日志、计时),需要可读的执行顺序。PostToolUse 多个 Replace 时"后注册赢"的语义需要确定性。hook 数量通常 1-3 个,并发收益可忽略。
2. Fail-open:异常视为 Continue
try {
handler(event)
} catch (e: Exception) {
logger.warn(e) { "Hook handler threw exception for ${event.type}, treating as Continue" }
HookResult.Continue
}这是与 s03 PermissionPipeline 的关键对比:
| 组件 | 异常处理策略 | 理由 |
|---|---|---|
| PermissionPipeline | Fail-closed(异常 → DENY) | 安全决策保守,宁可拒绝也不放过 |
| HookManager | Fail-open(异常 → Continue) | 可观测性增强宽松,不让 buggy hook 拖垮主流程 |
对比的哲学: 安全决策应该保守,可观测性增强应该宽松。一个抛异常的安全规则可能导致工具被错误拒绝(保守可接受);一个抛异常的日志 hook 不应该让工具调用失败(宽松更合理)。
3. 便捷方法返回原语,不是 HookResult
firePreToolUse 返回 String? 而非 HookResult,firePostToolUse 返回 ToolResult 而非 List<HookResult>。
聚合规则封装在 Manager 内部:
| Pre 聚合 | 结果 |
|---|---|
| 全部 Continue | null(放行) |
| 任一 Block | 第一个 Block 的 reason |
| 任一 Replace | warn + 视为 Continue |
| Post 聚合 | 结果 |
|---|---|
| 全部 Continue | 原 ToolResult |
| 任一 Replace | 最后一个 Replace 的 result(后注册赢) |
| 任一 Block | warn + 视为 Continue |
为什么返回原语? 因为这些值要传给 AgentLoop,而 AgentLoop 不能 import hooks 包(包依赖规则)。String? 和 ToolResult 都是 tool/标准库类型,让 agent 包零依赖。
第三层:回调注入 — 松耦合集成
与 s03 完全一致的设计哲学:AgentLoop 不 import hooks 包的任何内容。
AgentLoopHooks:回调集合容器
随着 Index 增长,AgentLoop 的可插拔回调会越来越多(s03 权限、s04 Pre/Post Hook,未来还可能有 s06 Subagent、s08 ContextCompact 的回调)。如果每个回调都作为构造函数参数,签名会迅速膨胀。
解决方案:用 data class 收纳所有回调,构造函数只接受一个 hooks 参数。
data class AgentLoopHooks(
val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null,
val onPreToolUse: (suspend (ToolCall) -> String?)? = null,
val onPostToolUse: (suspend (ToolCall, ToolResult) -> ToolResult)? = null
// 未来新增回调只改这里,AgentLoop 构造函数签名保持稳定
)
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPrompt: String,
private val config: AgentConfig = AgentConfig(),
private val hooks: AgentLoopHooks = AgentLoopHooks()
)收益:
- AgentLoop 构造函数稳定在 4 个参数,不随 Index 增长膨胀
- 默认值
AgentLoopHooks()禁用全部扩展,保持向后兼容——s01-s02 的测试零改动 - ReplLoop 用命名参数组装,意图清晰
回调签名设计
// s03:返回 Boolean,没有原因
onBeforeToolExecute: suspend (ToolCall) -> Boolean
// s04 Pre:返回 String?,携带阻止原因
onPreToolUse: suspend (ToolCall) -> String?
// s04 Post:接收原结果,返回可能修改后的结果
onPostToolUse: suspend (ToolCall, ToolResult) -> ToolResult为什么 onPreToolUse 返回 String? 而不是 Boolean? 因为 hook 阻止工具时需要带"原因"传给 LLM。LLM 看到 Blocked by hook: rate-limited 比 Permission denied: bash 信息量更大——前者告诉它"换个时机再试",后者只是"不行"。
为什么 onPostToolUse 返回 ToolResult 而不是 ToolResult?? 避免处理 null 的负担。handler 想保持原样就 return result,想替换就 return result.copy(...)。简单直接。
执行顺序固定
private suspend fun executeOneTool(toolCall: ToolCall): ToolResult {
// 1. 权限检查(s03)—— 安全闸门,最先执行
if (hooks.onBeforeToolExecute != null && !hooks.onBeforeToolExecute.invoke(toolCall)) {
return ToolResult(toolCall.id, "Permission denied: ${toolCall.name}", isError = true)
}
// 2. PreToolUse hooks(s04)—— 可观测/可阻止
if (hooks.onPreToolUse != null) {
val blockReason = hooks.onPreToolUse.invoke(toolCall)
if (blockReason != null) {
return ToolResult(toolCall.id, "Blocked by hook: $blockReason", isError = true)
}
}
// 3. 实际执行工具
val result = try {
ToolRegistry.get(toolCall.name).execute(toolCall.input).copy(toolCallId = toolCall.id)
} catch (e: Exception) {
ToolResult(toolCall.id, "Error: ${e.message}", isError = true)
}
// 4. PostToolUse hooks(s04)—— 可修改结果
return if (hooks.onPostToolUse != null) hooks.onPostToolUse.invoke(toolCall, result) else result
}顺序的语义:
| 情景 | 行为 |
|---|---|
| Permission DENY | 返回 permission denied;Pre/Post 都不触发(工具根本没尝试) |
| PreToolUse Block | 返回 hook blocked;Post 不触发(工具未实际执行) |
| 工具执行抛异常 | Post 仍触发,handler 可看到 isError=true 的结果 |
| PostToolUse Replace | 替换后的结果进入 messagesHistory 流向 LLM |
为什么 Permission DENY 不触发 Post? 工具根本没执行,"Post Tool Use" 语义不成立。如果 hook 看到一个被拒绝的 toolCall 但 result 是某种占位值,反而容易混淆——干净的语义是"工具未实际执行则不触发 Post"。
为什么工具抛异常时 Post 仍触发? handler 可能需要观测失败、记录指标。result.isError=true 让 handler 知道这是失败结果,可以选择继续记录或改写为更友好的错误消息。
Post 修改的结果如何流向 LLM
executeOneTool 返回 ToolResult
↓
executeTools 收集结果列表(可能并发)
↓
messagesHistory.add(Message(Role.USER, "", toolResults = toolResults))
↓
下一轮 LLM 请求看到修改后的 tool_result修改后天然流向 LLM,无需额外处理。 这是 Index 02 设计的延续——tool_result 块就是工具执行的真相,谁修改了它,下一轮 LLM 就看到什么。
第四层:内置 Hook 示例
LoggingHook(无状态)
class LoggingHook {
fun registerTo(manager: HookManager) {
val pre: HookHandler = { event ->
logger.debug { "[Hook] PreToolUse: ${event.toolCall.name} (id=${event.toolCall.id})" }
HookResult.Continue
}
val post: HookHandler = { event ->
val err = if (event.result?.isError == true) " (error)" else ""
logger.debug { "[Hook] PostToolUse: ${event.toolCall.name}$err" }
HookResult.Continue
}
manager.on(HookEvent.Type.PRE_TOOL_USE, pre)
manager.on(HookEvent.Type.POST_TOOL_USE, post)
}
}无状态,只读不写,始终 Continue。教学意义:展示最简 Hook 模式——pre 和 post 各注册一个 handler,互不影响。
TimingHook(有状态)
class TimingHook {
private val startTimes = ConcurrentHashMap<String, Long>()
val activeTimerCount: Int get() = startTimes.size
fun registerTo(manager: HookManager) {
val pre: HookHandler = { event ->
startTimes[event.toolCall.id] = System.nanoTime()
HookResult.Continue
}
val post: HookHandler = { event ->
val start = startTimes.remove(event.toolCall.id)
if (start != null) {
val elapsedMs = (System.nanoTime() - start) / NANOS_PER_MS
logger.info { "[Hook] Tool '${event.toolCall.name}' executed in ${elapsedMs}ms" }
}
HookResult.Continue
}
// ...
}
}关键设计:用 ConcurrentHashMap 按 toolCallId 关联 Pre 和 Post。
为什么?因为 AgentLoop 支持并发工具调用(Index 02 的智能并发)。两个 read_file 同时执行时,两个 Pre 事件可能交错触发,两个 Post 事件也会交错。用 toolCall.id 作为 key 可以正确配对——Pre 记录开始时间,Post 取出对应开始时间计算耗时。
为什么 activeTimerCount 暴露给外部? 主要供测试验证。日志输出是脆弱的测试断言目标(依赖 logback 配置),而计时器数量是可验证的行为契约。
踩坑记录
1. HookHandler 是 typealias,不能当构造器调用
// ❌ 编译错误:typealias 到 SuspendFunction1 没有构造器
val h = HookHandler { event -> HookResult.Continue }
// ✅ 显式标注类型
val h: HookHandler = { event -> HookResult.Continue }HookHandler 是 typealias HookHandler = suspend (HookEvent) -> HookResult,本质是函数类型别名,不是 class。Kotlin 不允许 typealias XXX { ... } 语法。
2. Kotest 的 shouldNotBe 与 null
最初写测试用 result shouldNotBe null,但 Kotest 没有内联的 shouldNotBe infix 函数处理 null,需要用 shouldNotBeNull() 扩展函数:
// ❌ 编译错误
postHandler.events[0].result shouldNotBe null
// ✅ 正确
import io.kotest.matchers.nulls.shouldNotBeNull
postHandler.events[0].result.shouldNotBeNull()3. FakeEchoTool 返回的是 JSON 字面值
// FakeEchoTool.execute 的实现
val text = input["message"]?.toString() ?: ""
return ToolResult(toolCallId = "fake_id", content = text)input["message"]?.toString() 取出的是 JSON 字面值 "x"(带引号),不是 x。写测试断言时写成 content shouldBe "x" 会失败,应该是 content shouldBe "\"x\""。
这反映了 kotlinx-serialization 的 API 行为:JsonPrimitive.toString() 返回带引号的 JSON 表示,要用 .jsonPrimitive.content 或 .contentOrNull 才能拿到原始字符串。FakeEchoTool 的实现是测试用的简化版,不需要修正——只需要修正测试断言。
4. 测试代码中避免重复断言
集成测试"Permission 拒绝优先于 Pre hook 阻止"最初写了两行一样的断言:
deniedResult.content shouldContain "Permission denied"
deniedResult.content shouldContain "Permission denied" // 重复复制粘贴时的疏忽。删除重复行即可。
5. 异常容错策略的对称对比
实现时反复纠结"hook 异常应该 fail-open 还是 fail-closed"。最终从设计哲学上想清楚了:
| 组件 | 失败时的影响 | 策略 |
|---|---|---|
| Permission 规则异常 | 可能放过本该拒绝的危险操作 | Fail-closed(视为 DENY) |
| Hook handler 异常 | 可能少记一次日志或漏掉一次计时 | Fail-open(视为 Continue) |
判断标准:这个组件的失败会让系统更安全还是更危险? 如果失败让系统更危险(漏过危险命令),要 fail-closed;如果失败只是少了可观测性(少记日志),fail-open 更合理。
6. AgentLoopHooks:构造函数膨胀的早期重构
初版实现把三个回调都作为 AgentLoop 构造函数的独立参数:
// 初版(弃用)
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPrompt: String,
private val config: AgentConfig = AgentConfig(),
private val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null,
private val onPreToolUse: (suspend (ToolCall) -> String?)? = null,
private val onPostToolUse: (suspend (ToolCall, ToolResult) -> ToolResult)? = null
)代码评审时发现趋势:s03→s04 从 1 个回调涨到 3 个(+200%),按 CLAUDE.md 路线图,s06 Subagent、s08 ContextCompact、s11 ErrorRecovery 都可能再加回调。到 5-6 个时重构成本会显著上升——"三次法则"支持现在动。
重构方案: 引入 AgentLoopHooks data class 收纳全部回调,AgentLoop 构造函数只接受一个 hooks 参数。未来新增回调只改 data class,构造函数签名保持稳定。这是用最小代价(多一层属性访问 hooks.onPreToolUse)换来长期的可维护性。
为什么不更进一步引入 ToolExecutionInterceptor 接口或直接注入 HookManager? 前者过度抽象(目前只有一种拦截场景),后者违反 agent 包不依赖 hooks 包的约束。data class 是最克制的方案。
关于 Permission 是否合并到 Hook 的评估: 评审时也考虑过把 PermissionPipeline 作为 pre-hook 接入,最终决定不合并。两者关注点正交——Permission 是 fail-closed 的安全决策,Hook 是 fail-open 的可观测性增强;聚合规则相反(任一 DENY 优先 vs 后注册赢);ASK 路径需要用户交互和记忆存储,塞不进 HookResult.Block 的字符串。保留 s03/s04 独立是符合既有设计的。
测试:32 个新用例,131 总计
Index 04 新增 23 个测试(12 HookManager + 6 BuiltInHooks + 5 AgentLoop 集成),加上 s03 代码审查时新增的部分,总用例从 99 增长到 131。
| 被测组件 | 测试方式 | 用例数 |
|---|---|---|
| HookManager | FakeHookHandler + ThrowingHookHandler | 12 |
| LoggingHook + TimingHook | 行为契约(不验证日志输出) | 6 |
| AgentLoop hook 集成 | FakeLLMProvider + FakeEchoTool | +5 |
HookManagerTest 核心用例
"handler throwing exception is treated as Continue (fail-open)" {
val manager = HookManager()
val afterThrow = FakeHookHandler(HookResult.Continue)
manager.on(HookEvent.Type.PRE_TOOL_USE, ThrowingHookHandler())
manager.on(HookEvent.Type.PRE_TOOL_USE, afterThrow)
val results = manager.fire(HookEvent(HookEvent.Type.PRE_TOOL_USE, makeToolCall()))
// 抛异常的 handler 视为 Continue;后续 handler 仍执行
results shouldBe listOf(HookResult.Continue, HookResult.Continue)
afterThrow.callCount shouldBe 1
}AgentLoop 集成测试关键断言
"permission denial takes precedence over pre-hook block" {
val loop = AgentLoop(
// ...
hooks = AgentLoopHooks(
onBeforeToolExecute = { false }, // Permission 拒绝
onPreToolUse = { "should-not-reach" } // 此 hook 不应被调用
)
)
loop.run("Test")
// 历史中应是 Permission denied,而非 Blocked by hook
deniedResult.content shouldContain "Permission denied"
}这个测试验证了执行顺序:Permission 先执行,拒绝后 Pre hook 根本不会被调用。
BuiltInHooksTest 不验证日志输出
TimingHook 的测试不读日志(脆弱),而是检查 activeTimerCount:
"TimingHook consumes timer on PostToolUse" {
manager.firePreToolUse(makeToolCall(id = "t1"))
manager.firePostToolUse(makeToolCall(id = "t1"), makeResult())
timing.activeTimerCount shouldBe 0
}文件清单
src/main/kotlin/com/sepcai/code/
├── hooks/ # [新建] Hook 包
│ ├── HookEvent.kt # 事件模型 + Type 枚举
│ ├── HookResult.kt # sealed class: Continue/Block/Replace
│ ├── HookHandler.kt # typealias suspend (HookEvent) -> HookResult
│ ├── HookManager.kt # 分发器 + firePreToolUse/firePostToolUse
│ └── builtins/
│ ├── LoggingHook.kt # 无状态日志 hook
│ └── TimingHook.kt # 有状态计时 hook(ConcurrentHashMap)
├── agent/
│ ├── AgentLoopHooks.kt # [新建] 回调集合 data class,收纳全部可插拔回调
│ └── AgentLoop.kt # [修改] 构造函数改用 hooks 参数,executeOneTool 重构
└── repl/
└── ReplLoop.kt # [修改] 构建 HookManager 通过 AgentLoopHooks 注入,欢迎语改 Index 04
src/test/kotlin/com/sepcai/code/
├── agent/
│ └── AgentLoopTest.kt # [修改] +5 个 hook 集成测试(改用 hooks 命名参数)
└── hooks/ # [新建]
├── HookManagerTest.kt # 12 个测试
└── builtins/
└── BuiltInHooksTest.kt # 6 个测试9 个源文件(7 新建 + 2 修改)· 2 个新测试文件 + 1 个测试修改 · 131 个测试 · 全部通过。
下一站
阶段一(最小闭环)已经全部完成。从 s01 到 s04,智能体从"只会说话"成长为"能读会写、安全可控、可观测的工具调用系统"。
但智能体仍然没有"规划能力"——它接到一个复杂任务,会一步一步试探着做,而不是先想清楚再做。Index 04 的 PostToolUse hook 虽然能观测结果,但智能体本身没有"我接下来要做什么"的概念。
Index 05: TodoWrite 将引入待办管理,让智能体先计划后执行。核心设计:
TodoItem数据模型——状态(pending/in_progress/completed)+ 优先级TodoManager管理器——增删改查,支持 LLM 通过工具调用更新TodoWriteTool内置工具——让 LLM 显式声明计划,避免走一步看一步
阶段一打下了基础(AgentLoop + Tool + Permission + Hook),阶段二开始让智能体真正具备智能体的能力——规划、子智能体、技能加载、上下文压缩、跨会话记忆。
Hooks 与 TodoWrite 的衔接点:PostToolUse hook 可以观测工具执行后的状态变化,未来可以触发 TodoManager 自动更新待办状态("read_file 完成 → 标记对应的 todo 为 completed")。但这是后续 Index 的任务,s04 只负责把扩展点准备好。


