目标
s01 到 s04 让智能体从"只会说话"成长为"能读会写、安全可控、可观测的工具调用系统"。但接到一个多步骤任务时,它仍然是"走一步看一步"——读个文件、改两行、再读、再改。中间没有"我接下来要做什么"的概念。
Index 05 的目标是给智能体装上规划能力:
- 定义
TodoItem极简数据模型——content + status 三态(pending / in_progress / completed) - 实现
TodoStore全局单例存储——线程安全,整体替换语义 - 实现
TodoWriteTool工具——让 LLM 通过 tool_use 显式更新待办列表 - 接入 ReplLoop——注册工具、
/clear清空待办 - AgentLoop 零修改——TodoWriteTool 是普通 Tool,自然被 ToolRegistry 发现和调度
完成后的效果——智能体接到复杂任务时,会先调用 todo_write 列计划:
> 帮我给 UserService.kt 加上日志,并修复 getUserById 的 NPE
[Agent 调用 todo_write 创建 3 项待办]
Todo list updated (0/3 completed, 1 in progress, 2 pending):
1. [~] Read UserService.kt
2. [ ] Add logging to all public methods
3. [ ] Fix NPE in getUserById
[Agent 调用 read_file]
[Agent 调用 todo_write 更新:第 1 项完成,第 2 项 in_progress]
Todo list updated (1/3 completed, 1 in progress, 1 pending):
1. [x] Read UserService.kt
2. [~] Add logging to all public methods
3. [ ] Fix NPE in getUserByIdLLM 自己维护进度,用户也看得到全貌。
架构:最小闭环的"计划本"
Index 05 在 s02-s04 的工具链路上增加了一个"内存记事本"——它是普通工具,没有特殊地位:
用户输入 "帮我重构 X"
│
▼
┌──────────────────┐
│ AgentLoop │
│ messagesHistory │
└──┬─────────────┘
│ LLM 决定调用 todo_write
▼
┌──────────────────────────────────┐
│ executeOneTool (s02-s04 链路) │
│ │
│ ① Permission (s03) ──── 放行 │
│ ② PreToolUse Hook (s04) ─ 放行 │
│ ③ TodoWriteTool.execute │
│ │ │
│ ▼ │
│ TodoStore.replaceAll(items) │
│ │ │
│ ▼ │
│ 返回状态概览文本 │
│ ④ PostToolUse Hook (s04) ─ 透传 │
└──────────────────────────────────┘
│
▼
tool_result 进入 messagesHistory
LLM 下一轮能看到当前 todo 状态关键洞察:TodoWriteTool 与 ReadFileTool / WriteFileTool / BashTool 是同辈工具。它没有任何"特殊通道"——穿过同一个 Permission 管线,经过同一组 Pre/Post hooks,由同一个 AgentLoop 调度。唯一不同的是它修改的是内存状态而非文件系统。
| 层 | Index 04 状态 | Index 05 变更 |
|---|---|---|
| 数据模型 | — | TodoItem (content + status) + TodoStatus 枚举 |
| 存储 | — | TodoStore 全局单例(CopyOnWriteArrayList) |
| 工具 | 3 个内置 | +TodoWriteTool(isReadOnly=false) |
| 智能体 | AgentLoopHooks 已稳定 | 零修改 |
| REPL | 欢迎语 Index 04 | 注册 TodoWriteTool + /clear 清空 TodoStore |
第一层:数据模型
TodoItem——极简三字段
data class TodoItem(
val content: String,
val status: TodoStatus = TodoStatus.PENDING
)
enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED }为什么这么简?因为 s05 阶段没有持久化(CLAUDE.md 决策 4:内存存储优先),也不需要排序——LLM 用数组下标定位。加了 id / priority / createdAt 都是"为未来预留",违反 CLAUDE.md "严格迭代,不做预留" 原则。
没有状态机校验:理论上"同时只能一个 IN_PROGRESS"是合理的约束,但 TodoStore 不强制。原因有二:
- 校验会让 TodoWriteTool 变成有状态工具,错误处理复杂
- LLM 在 system prompt 引导下基本能自律,过度校验反而误伤合法场景
未来 s07 Skill Loading 阶段若引入更复杂的规划模式,再加状态机不迟。
第二层:存储
TodoStore——全局单例 + CopyOnWriteArrayList
object TodoStore {
private val items: CopyOnWriteArrayList<TodoItem> = CopyOnWriteArrayList()
fun replaceAll(newItems: List<TodoItem>) {
items.clear()
items.addAll(newItems)
}
fun getAll(): List<TodoItem> = items.toList() // 快照副本
fun clear() { items.clear() }
fun inProgressCount(): Int = items.count { it.status == TodoStatus.IN_PROGRESS }
fun completedCount(): Int = items.count { it.status == TodoStatus.COMPLETED }
fun size(): Int = items.size
}参考 s02 的 ToolRegistry 单例风格——Index 05-11 阶段使用全局 object,s06 Subagent 需要多实例隔离时再改为 class + 注入(ToolRegistry.kt:9 的注释已经预告了这个演进)。
为什么用 CopyOnWriteArrayList?
- s02 AgentLoop 已支持工具并发调用——读操作(read_file)会并行执行
- TodoWriteTool 的
isReadOnly=false让它走串行分支,理论上不会被并发调用 - 但 TodoStore 作为全局可变状态,仍需防御未来直接调用场景(s06 Subagent、自定义 hook 等)
- 读多写少场景下 CopyOnWriteArrayList 读路径无锁,性能优于 synchronizedList
为什么 getAll() 返回 toList() 副本?
"getAll returns a snapshot copy that external mutation cannot affect" {
TodoStore.replaceAll(listOf(TodoItem("Original", TodoStatus.PENDING)))
val snapshot = TodoStore.getAll()
// 尝试修改快照——toList() 返回不可变 List,add 抛 UnsupportedOperationException
(snapshot as? MutableList<TodoItem>)?.add(TodoItem("Hijacked", TodoStatus.COMPLETED))
TodoStore.size() shouldBe 1 // 内部状态不变
TodoStore.getAll()[0].content shouldBe "Original"
}这是不可变契约——外部拿到的 List 不能反向影响内部状态。CopyOnWriteArrayList.toArray() 本就返回新数组,但 toList() 包一层更明确语义并返回 Kotlin 不可变 List 视图。
整体替换语义:replaceAll 是核心写方法,每次调用覆盖整个列表,不合并、不增量。这与 Anthropic 官方 TodoWrite 工具的设计一致——LLM 不需要记住"上次写到哪了",每次传完整数组即可。简单、无状态冲突。
第三层:TodoWriteTool
工具签名
class TodoWriteTool : Tool {
override val name = "todo_write"
override val description = "Update the todo list (plan before executing, track progress)"
override val isReadOnly = false // 修改内存状态,串行执行
override val jsonSchema: JsonObject = ...
override suspend fun execute(input: JsonObject): ToolResult
}与 WriteFileTool / BashTool 完全平行——没有特殊接口、没有特殊处理。
isReadOnly = false 的含义:s02 AgentLoop 的 executeTools 按 isReadOnly 分组——读操作并行,写操作串行。TodoWriteTool 标记为写操作,避免与自身并发覆盖(虽然 TodoStore 本身并发安全,但语义上待办列表更新应有序)。
JSON Schema 结构
{
"name": "todo_write",
"description": "Use this tool to create and update a todo list ...",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "Complete list of todos (replaces existing list)",
"items": {
"type": "object",
"properties": {
"content": { "type": "string" },
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"]
}
},
"required": ["content", "status"]
}
}
},
"required": ["todos"]
}
}description 引导 LLM "先计划后执行":在工具 description 里明确写 "Plan before executing multi-step tasks, then update statuses as you progress"——配合 systemPrompt 形成双重引导。这是让 LLM "会用"工具的关键。
execute 执行逻辑
override suspend fun execute(input: JsonObject): ToolResult {
// 1. 取 todos 数组(runCatching 防御类型不匹配)
val todosArray = ...
?: return ToolResult("", "Error: todos is required and must be an array", isError = true)
// 2. 逐项解析并校验——错误消息带 todos[$index] 位置
val parsed = mutableListOf<TodoItem>()
for ((index, element) in todosArray.withIndex()) {
val obj = runCatching { element.jsonObject }.getOrNull()
?: return ToolResult("", "Error: todos[$index] must be an object", isError = true)
val content = runCatching { obj["content"]?.jsonPrimitive?.content }.getOrNull()
?: return ToolResult("", "Error: todos[$index].content is required and must be a string", isError = true)
val statusStr = runCatching { obj["status"]?.jsonPrimitive?.content }.getOrNull()
?: return ToolResult("", "Error: todos[$index].status is required and must be a string", isError = true)
val status = parseStatus(statusStr)
?: return ToolResult("", "Error: todos[$index].status '$statusStr' is invalid; must be one of: pending, in_progress, completed", isError = true)
parsed.add(TodoItem(content = content, status = status))
}
// 3. 整体替换存储
TodoStore.replaceAll(parsed)
// 4. 返回状态概览文本
return ToolResult("", buildSummary(parsed))
}关键设计要点:
- 逐项精确报错:错误消息带
todos[$index],LLM 自我修正时知道改哪一项 - runCatching 包裹类型转换:kotlinx.serialization 的
jsonObject/jsonPrimitive在类型不匹配时抛异常,必须防御 - 失败时不部分写入:解析阶段先收集到
parsed临时列表,全部通过后才replaceAll——避免半成功状态 - status 大小写不敏感:
parseStatus用value.lowercase(),LLM 写"IN_PROGRESS"也能解析 - toolCallId 返回空字符串:AgentLoop.executeOneTool 会自动用真实 id 覆盖(s02 既有约定,参考 WriteFileTool)
返回的状态概览
Todo list updated (1/3 completed, 1 in progress, 1 pending):
1. [x] Read UserService.kt
2. [~] Add logging to all public methods
3. [ ] Fix NPE in getUserById人类可读格式,LLM 单次调用即可看到全貌,无需额外查询工具。[x] [~] [ ] 三个标记借鉴 git diff 的视觉风格。
空数组时返回 "Todo list cleared."——明确语义,不是错误。
ReplLoop 集成
三处修改
// 1. 注册工具(start() 内)
ToolRegistry.register(ReadFileTool())
ToolRegistry.register(WriteFileTool())
ToolRegistry.register(BashTool())
ToolRegistry.register(TodoWriteTool()) // 新增
// 2. /clear 命令清空 TodoStore
CMD_CLEAR, CMD_C -> {
agentLoop.messagesHistory.clear()
approvalStore.clear()
TodoStore.clear() // 新增
logger.info {"Conversation history, permission memory, and todo list cleared"}
println("Conversation history, permission memory, and todo list cleared.")
true
}
// 3. 欢迎语
println("🐱 Cat-Code — Index 05: TodoWrite")为什么 /clear 要清空 TodoStore?
用户输入 /clear 表示要"重新开始"——待办列表应随之清空,否则下次对话 LLM 看到陈旧待办,可能基于已完成任务继续推理。与 ApprovalStore 一起 clear,保持"清空所有可变状态"的一致语义。
这与 s04 LoggingHook(无状态)不同——TodoStore 是有状态存储,必须显式清理。
测试策略
遵循 s02-s04 的 Fake 模式,不引入 mock 框架。直接调用 TodoStore.clear() 做测试隔离,与 ToolRegistry.clear() 风格一致。
TodoStoreTest(12 用例)
replaceAll stores given items ✓
replaceAll with empty list clears existing items ✓
replaceAll is idempotent when called twice with same input ✓
replaceAll overwrites previous items completely ✓
getAll returns empty list by default ✓
getAll returns a snapshot copy that external mutation cannot affect ✓
clear empties all items ✓
clear on empty store is no-op ✓
inProgressCount returns count of IN_PROGRESS items ✓
completedCount returns count of COMPLETED items ✓
size returns total count ✓
replaceAll is thread-safe under concurrent writes ✓并发测试:50 个协程并发调用 replaceAll,最终状态应是某次完整写入的结果(不出现部分覆盖):
"replaceAll is thread-safe under concurrent writes" {
runBlocking {
coroutineScope {
(1..50).map { i ->
async {
TodoStore.replaceAll(listOf(TodoItem("item-$i", TodoStatus.COMPLETED)))
}
}.awaitAll()
}
}
TodoStore.size() shouldBe 1 // 最终是某次完整写入的结果
TodoStore.getAll()[0].content shouldStartWith "item-"
}TodoWriteToolTest(12 用例)
execute replaces todo store with given items ✓
execute returns summary containing status overview ✓
execute with empty todos array clears the store ✓
execute returns error when todos is missing ✓
execute returns error when todos is not an array ✓
execute returns error when todo item is not an object ✓
execute returns error when content is missing ✓
execute returns error when status is missing ✓
execute returns error when status value is invalid ✓
execute accepts status case-insensitively ✓
execute replaces previous list completely ✓
execute reports error index correctly for second item ✓AgentLoopTest 集成测试(+3 用例)
run updates TodoStore when LLM calls todo_write ✓
run passes todo_write tool definition to LLM ✓
run does not modify TodoStore when tool execution is blocked ✓第三个测试特别重要——通过 s04 的 onPreToolUse hook 阻止工具执行,验证 TodoStore 未被修改。这证明了 s04 的 Hook 系统对 s05 工具有效,两层架构协同工作。
afterTest 隔离扩展
afterTest {
ToolRegistry.clear()
TodoStore.clear() // 新增
}跨测试用例不能污染——每个测试都应从空 TodoStore 开始。
关键设计取舍
| 决策点 | 选择 | 理由 |
|---|---|---|
| TodoStore 并发容器 | CopyOnWriteArrayList | 读多写少,读路径无锁;防御未来直接调用场景 |
| getAll 返回类型 | List(toList 副本) | 防外部修改内部状态,不可变契约 |
| TodoWriteTool.isReadOnly | false | 修改内存状态,串行执行避免自覆盖 |
| ToolResult 返回内容 | 状态概览文本 | LLM 单次调用即可见全貌,无需二次查询 |
| TodoWriteTool 包路径 | tool/tools/ | 与 3 个内置工具一致;todo/ 只放纯领域模型 |
| TodoItem 字段 | content + status(默认 PENDING) | 极简,未来 s09 Memory/s12 持久化时再扩 |
| 状态机校验 | 不做 | LLM 自律,避免过度工程化 |
| /todo REPL 命令 | 不加 | TodoStore 是工具内部状态,不对用户暴露 |
| toolCallId 填充 | 返回 "",AgentLoop 自动覆盖 | s02 既有约定 |
| TodoStore 扩展点 | object 单例,s06 重构为 class | 遵循"严格迭代",不为未来预留抽象 |
| 解析失败处理 | 不部分写入 | 收集到 parsed 临时列表,全部通过后才 replaceAll |
踩坑记录
1. kotlinx.serialization 的类型转换会抛异常
最初写 input["todos"]?.jsonArray,但 jsonArray 是扩展属性,类型不匹配时直接抛 IllegalStateException,不会返回 null:
// ❌ 类型不匹配时抛异常
val todosArray = input["todos"]?.jsonArray
// ✅ runCatching 防御
val todosElement = input["todos"]
val todosArray = when {
todosElement == null -> null
else -> runCatching { todosElement.jsonArray }.getOrNull()
}同样 element.jsonObject 和 obj["content"]?.jsonPrimitive?.content 都需要 runCatching 防御。LLM 传入畸形 JSON 时不能让整个 AgentLoop 崩溃。
2. toList() 返回不可变 List,as? MutableList 会失败
最初的测试写法:
@Suppress("UNCHECKED_CAST")
(snapshot as? MutableList<TodoItem>)?.add(TodoItem("Hijacked", TodoStatus.COMPLETED))发现 as? MutableList 实际上会成功(返回非 null),但调用 add 抛 UnsupportedOperationException——因为 Kotlin 的 toList() 返回的是 Arrays.asList() 包装的不可变视图。
修正后的测试:捕获 UnsupportedOperationException 即可,这正是"外部无法修改"的预期行为。
3. TodoWriteTool 不需要注入 TodoStore
最初考虑用构造函数注入 TodoStore 实例:
class TodoWriteTool(private val store: TodoStore = TodoStore) : Tool { ... }但 TodoStore 是 object 单例,无法作为类型参数传递。若改成 class 又违反"严格迭代"原则——s05 阶段没有多实例需求。
最终决定:TodoWriteTool 直接引用 TodoStore 全局单例,与 WriteFileTool 直接调用 File() 风格一致。s06 Subagent 重构时再抽接口。
4. 测试失败的错误消息要带位置
最初写的错误消息:
return ToolResult("", "Error: status is required", isError = true)LLM 看到 "status is required" 不知道是哪一项的 status 缺失。改为带 todos[$index]:
return ToolResult("", "Error: todos[$index].status is required and must be a string", isError = true)LLM 自我修正时能直接定位到 todos[1].status,效率高很多。
5. UI 展示逻辑不该混入 AgentLoop 组装代码
最初实现"todo_write 后展示给用户"时,直接在 ReplLoop 的 onPostToolUse lambda 里硬编码:
// ❌ 三个问题
onPostToolUse = { tc, result ->
val finalResult = hookManager.firePostToolUse(tc, result)
if (tc.name == "todo_write" && !finalResult.isError) { // ① 字符串硬编码
println() // ② 硬编码 println
println(finalResult.content)
println()
}
finalResult
}问题:
- 字符串硬编码工具名——
"todo_write"重复了TodoWriteTool.name的真相,后续改名要同步两处 - 展示逻辑与组装混杂——ReplLoop 同时承担"组装 AgentLoop"和"UI 展示"职责
- 硬编码 println 无扩展性——无法测试展示行为,也无法被其他场景(GUI、日志面板)复用
重构方案——抽取 TodoDisplayHook,与 LoggingHook / TimingHook 同辈:
// ✅ 优雅版本
class TodoDisplayHook(
private val output: (String) -> Unit = { msg -> println(msg) } // 注入便于测试
) {
fun registerTo(manager: HookManager) {
val handler: HookHandler = handler@{ event ->
if (event.toolCall.name != TodoWriteTool.NAME) { // 引用常量
return@handler HookResult.Continue
}
// ...输出概览...
HookResult.Continue // 不替换 result
}
manager.on(HookEvent.Type.POST_TOOL_USE, handler)
}
}
// ReplLoop 只需一行注册
TodoDisplayHook().registerTo(hookManager)关键设计:
- TodoWriteTool.NAME 常量:
companion object暴露const val NAME = "todo_write",消除字符串硬编码 - 依赖方向合法:
TodoDisplayHook放在todo/包,依赖hooks+tool单向;若放hooks/builtins/会反向依赖todo包 - output 函数注入:默认
println,测试可注入mutableListOf<String>()收集输出 - 返回 Continue:hook 只做副作用(println),不替换 result——用户看到的 println 和 LLM 看到的 tool_result 是同一份内容的副本
这正是 s04 Hooks 架构的价值——新增 UI 展示能力时零修改 AgentLoop,只需注册一个新 hook。
文件清单
src/main/kotlin/com/sepcai/code/
├── todo/ # [新建] 待办领域模型包
│ ├── TodoItem.kt # data class + TodoStatus 枚举
│ ├── TodoStore.kt # 全局单例存储(CopyOnWriteArrayList)
│ └── TodoDisplayHook.kt # [新建] PostToolUse hook,todo_write 后镜像输出到 stdout
├── tool/
│ └── tools/
│ └── TodoWriteTool.kt # [新建] 实现 Tool 接口的 todo_write 工具(+NAME 常量)
└── repl/
└── ReplLoop.kt # [修改] 注册工具 + TodoDisplayHook + /clear 清空 + 欢迎语
src/test/kotlin/com/sepcai/code/
├── todo/ # [新建]
│ ├── TodoStoreTest.kt # 12 个用例
│ └── TodoDisplayHookTest.kt # [新建] 6 个用例(输出/错误过滤/工具过滤/Continue)
├── tool/
│ └── TodoWriteToolTest.kt # [新建] 12 个用例
└── agent/
└── AgentLoopTest.kt # [修改] +3 个集成测试,afterTest 加 TodoStore.clear()7 个源文件(4 新建 + 3 修改)· 3 个新测试文件 + 1 个测试修改 · 164 个测试 · 全部通过。
下一站
s05 完成了阶段二的第一个能力——规划。智能体现在能:
- 在执行前显式列出步骤
- 过程中更新进度
- 用户和 LLM 都能看到当前状态
但 TodoWriteTool 是单线程的——所有 todo 由主 AgentLoop 维护。当你让智能体"重构 UserService 和 OrderService 两个文件"时,它仍然串行处理,无法让子智能体并行处理两个独立任务。
Index 06: Subagent 将引入子智能体——让主智能体可以分发独立任务给隔离的子智能体执行。核心设计:
Subagent数据模型——隔离的 AgentLoop 实例 + 独立 messagesHistoryTask工具——让主智能体通过 tool_use 派发任务- ToolRegistry 改为 class——每个子智能体有独立的工具注册表(s05 的 object 单例会破坏隔离)
- TodoStore 改为 class——同样原因,每个子智能体有独立的待办列表
这个重构面不小——ToolRegistry 和 TodoStore 都要从 object 改成 class,ReplLoop 要负责创建实例并注入。但收益巨大:智能体可以真正并行处理独立任务,每个子智能体有自己的上下文(不污染主对话)。
阶段一(最小闭环)已经全部完成,阶段二(智能体能力)正在让智能体真正具备智能体的能力——规划、子智能体、技能加载、上下文压缩、跨会话记忆。


