learn-claude-code s05 详解:TodoWrite 工具与 Reminder 机制如何赋予 Coding Agent 规划能力
在 learn-claude-code 课程化构建 Claude Code 式 agent harness 的 17 章中,s05 专门解决一个普遍问题:长任务执行到一半时,Agent 会被中间结果吸走注意力而"跑偏"。本章通过一个带状态的 todo_write 工具、一套输入校验规则和一个"连续 3 轮未更新就注入提醒"的 reminder 计数器,让 Agent 先列计划再动手、边做边更新进度。读完后你会掌握:TodoManager 的完整校验与渲染实现、todo_write 如何接入既有工具分发表、reminder 在 agent loop 中的精确注入位置,以及配套的测试如何验证这些行为。
问题:为什么复杂任务会"做着做着就偏了"
文档给出的典型场景是:给 Agent 一个复合任务——"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败"。Agent 改了 3 个文件、跑了测试、发现 2 个失败并开始修 bug,修着修着忘了最初目标是"改成 snake_case",注意力全被测试失败吸走了(见 s05_todo_write/README.md 的 The Problem 一节)。
问题随对话变长而恶化:工具结果不断填满上下文,系统提示(SYSTEM prompt)的影响力被逐步稀释。一个 10 步的重构任务,做完第 1~3 步后,Agent 开始即兴发挥——因为第 4~10 步早已被挤出注意力窗口。
也就是说,缺的不是执行能力,而是把计划持续外化、反复暴露给模型注意力的机制。这正是 s05 要补上的"harness 层":Planning——让 Agent 在动手之前先想清楚。
总体方案:状态化 TODO 列表 + 提醒注入
S05 在前几章(s02 工具使用、s03 权限、s04 Hooks)的基础上只做两件事:
- 新增
todo_write工具,由TodoManager维护一份内存中的任务列表; - 在 agent loop 中加入
rounds_since_todo计数器,连续三个工具调用轮次没有调用todo_write时,把<reminder>Update your todos.</reminder>追加到第三轮的工具结果中,随后计数器清零。
关键设计取向是:todo_write 只更新计划状态,实际工作仍然由原有的 bash / read_file / write_file / edit_file / glob 完成。新工具走的是同一条 TOOL_HANDLERS[block.name] 分发路径,没有为规划引入任何旁路机制。
此外,SYSTEM prompt 从通用提示改为带规划引导的提示(见 s05_todo_write/code.py):
# s05 change: SYSTEM prompt adds planning guidance
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Before starting any multi-step task, use todo_write to plan your steps. "
"Update status as you go."
)
TodoManager:状态容器、校验与渲染
核心实现在 s05_todo_write/code.py 的 TodoManager 类。它持有内存列表 items,负责校验每次更新,并把渲染结果同时返回给模型和打印到终端。
输入解析:JSON 或 Python 列表字面量,但不使用 eval
update 方法同时接受 list 和 str 两种入参。字符串输入先尝试 json.loads,失败后再退回 ast.literal_eval(可安全解析 Python 字面量),两者都失败则抛出 ValueError(见 code.py):
def update(self, todos: list | str) -> str:
if isinstance(todos, str):
try:
todos = json.loads(todos)
except json.JSONDecodeError:
try:
todos = ast.literal_eval(todos)
except (SyntaxError, ValueError) as e:
raise ValueError("todos must be a list or JSON array string") from e
if not isinstance(todos, list):
raise ValueError("todos must be a list")
if len(todos) > 20:
raise ValueError("Max 20 todos allowed")
这里有个值得注意的工程细节:LLM 有时会以字符串形式返回 JSON 数组(而非结构化的 tool input),直接丢弃会让工具在真实对话中频繁"哑火"。而解析字符串又不安全——测试专门用 __import__('pathlib').Path(...).write_text('bad') 这样的恶意字符串验证 run_todo_write 只会返回 Error: 且不会触发任何执行(见 tests/test_todo_write_string_input.py 的 test_issue_340_does_not_eval_string_inputs)。ast.literal_eval 只能解析字面量、不执行表达式,是"兼容字符串输入"与"安全"之间的折中。同一测试文件还覆盖了两类合法字符串输入:JSON 数组字符串和 Python 列表表示字符串(test_todo_write_string_input.py)。
校验规则:20 项上限、非空 content、唯一 in_progress
校验逻辑集中在 code.py:
validated = []
in_progress_count = 0
for index, todo in enumerate(todos):
if not isinstance(todo, dict):
raise ValueError(f"todos[{index}] must be an object")
content = str(todo.get("content", "")).strip()
status = str(todo.get("status", "pending")).lower()
if not content:
raise ValueError(f"todos[{index}] requires content")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"todos[{index}] has invalid status '{status}'")
if status == "in_progress":
in_progress_count += 1
validated.append({"content": content, "status": status})
if in_progress_count > 1:
raise ValueError("Only one todo can be in_progress at a time")
self.items = validated
return self.render()
规则汇总:
| 规则 | 约束 | 违反时的错误 |
|---|---|---|
| 条数上限 | 一次更新最多 20 项 | Max 20 todos allowed |
| 必填 content | 每项 content 去除空白后必须非空 |
todos[i] requires content |
| 状态枚举 | pending / in_progress / completed(小写归一化),缺省为 pending |
todos[i] has invalid status '...' |
| 并发约束 | 同一时刻最多一个 in_progress |
Only one todo can be in_progress at a time |
两条设计动机:20 项上限防止模型把 TODO 列表当成"倾倒场",倒逼它维护一份真正可跟踪的精简计划;"唯一 in_progress"则强制模型在任一时刻明确"我正在做哪一步",这正是渲染输出中 [>] 标记存在的意义。
校验是"先全部验证、再整体替换":任何一条非法都会抛错,self.items 保持上一次的有效状态不变。tests/test_todo_write_string_input.py 的 test_rejects_invalid_updates_without_replacing_state 先写入一份合法状态 keep this,再依次投递空 content、双 in_progress、21 项三种非法更新,断言每次都返回 Error: 且 TODO.items 未被篡改。
渲染:给模型和人类各一份视图
render(code.py)把状态渲染成模型可读的文本,用三种标记表达状态:
marker = {
"pending": "[ ]",
"in_progress": "[>]",
"completed": "[x]",
}[todo["status"]]
lines.append(f"{marker} {todo['content']}")
# ...
lines.append(f"\n({done}/{len(self.items)} completed)")
例如更新后模型会收到:
[>] Inspect parser
[ ] Refactor parsing branch
[ ] Add regression test
(0/3 completed)
外层入口 run_todo_write(code.py)把校验异常转成 Error: 字符串返回给模型(而不是让异常炸掉整个 loop),同时用黄色高亮把当前任务列表打印到终端,供人类旁观:
def run_todo_write(todos: list | str) -> str:
try:
output = TODO.update(todos)
except ValueError as e:
return f"Error: {e}"
print(f"\n\033[33m## Current Tasks\033[0m\n{output}")
return output
TODO = TodoManager() 是模块级单例(code.py),整个会话共享一份状态。
工具注册:todo_write 如何接入既有分发表
todo_write 的工具定义与既有 5 个工具并列进 TOOLS 列表(code.py),JSON Schema 与校验逻辑一一对应:
{"name": "todo_write",
"description": "Create and manage a task list for your current coding session.",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"properties": {
"content": {"type": "string", "minLength": 1},
"status": {"type": "string",
"enum": ["pending", "in_progress", "completed"]},
},
"required": ["content", "status"],
},
},
},
"required": ["todos"],
}},
注意 Schema 里的 maxItems: 20 与 minLength: 1 和 TodoManager 的运行时校验是双层约束:Schema 负责在 API 层约束模型输出,TodoManager 负责兜底(比如字符串输入路径下,Schema 约束并不总是生效)。
分发注册只有一行(code.py):
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
}
s04 引入的 Hooks 体系(UserPromptSubmit / PreToolUse / PostToolUse / Stop)在 s05 中全部保留,todo_write 的每次调用同样会经过 PreToolUse 的 permission hook 与日志 hook——规划工具不享受权限豁免。
Reminder 机制:计数器与注入点
reminder 的全部逻辑就在 agent loop 的 15 行之内(code.py)。每轮循环开始时重置判断,处理完当前响应的所有 tool_use block 后更新计数:
def agent_loop(messages: list):
rounds_since_todo = 0
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
results = []
used_todo = False
for block in response.content:
if block.type != "tool_use":
continue
# ... PreToolUse 钩子、TOOL_HANDLERS 分发、PostToolUse 钩子 ...
if block.name == "todo_write":
used_todo = True
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(output)})
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.append({"type": "text",
"text": "<reminder>Update your todos.</reminder>"})
rounds_since_todo = 0
messages.append({"role": "user", "content": results})
从源码结构看,有三个实现细节值得留意:
- 计数粒度是"轮"(一次 assistant 响应),不是"工具调用"。同一轮里模型可能并发发起多个 tool_use block,只要其中有一个是
todo_write,used_todo即为 True,整轮清零。 - reminder 是"搭车"注入的:它不是独立的一轮 user 消息,而是作为
{"type": "text"}block 与第三轮的 tool_result 一起,拼进同一条 user 消息(messages.append({"role": "user", "content": results}))。这保证了对话角色交替的合法性,也让提醒与"刚完成的那批工具结果"强绑定。 - 触发即清零(
rounds_since_todo = 0),所以 reminder 最多每 3 轮出现一次,不会形成刷屏。
测试 tests/test_todo_write_string_input.py 的 test_appends_one_reminder_to_the_third_tool_result_batch 用一个假 client 连发 3 轮纯 glob 调用 + 1 轮 end_turn,精确断言:前两个 user 消息批次的 content 里没有任何 text block,只有第三个批次恰好包含 {"type": "text", "text": "<reminder>Update your todos.</reminder>"}。
典型工作流由此闭环:Agent 收到任务 → 先调 todo_write 列出全部步骤(全 pending)→ 挑一步设为 in_progress 开工 → 完成后设为 completed → 看下一个 pending 继续;如果中途"忘"了更新,reminder 会在第三轮把它拽回来。
文档给出的关键洞察是:todo_write 不给 Agent 增加任何执行能力,它增加的是规划能力。这一点在 web/src/data/scenarios/s05.json 的可视化剧本中体现得很清楚:todo_write 的 tool_result 回显当前列表,普通工具(read_file)照常走同一张分发表,三轮无更新后 harness 注入 reminder,模型的下一步动作被规划状态引导("I inspected the parser and will update the todo list before making the code change.")。
相对 s04 的变更
| 组件 | 之前 (s04) | 之后 (s05) |
|---|---|---|
| 工具数量 | 5(bash, read, write, edit, glob) | 6(+todo_write) |
| 规划能力 | 无 | 带状态的 TODO 列表 + reminder |
| SYSTEM 提示 | 通用提示 | 加入 "先计划再执行" 引导 |
| 循环 | 工具分发与 Hooks | 保留分发路径,加入 rounds_since_todo 与 reminder 注入 |
动手跑一遍
运行环境需要 Python 3.10+(代码使用了 list | str 联合类型语法)、anthropic 与 python-dotenv 依赖(见 requirements.txt),并通过 .env 提供 MODEL_ID(如需走代理网关还可设置 ANTHROPIC_BASE_URL,此时代码会自动弹出 ANTHROPIC_AUTH_TOKEN,见 code.py):
cd learn-claude-code
python s05_todo_write/code.py
启动后是交互式 REPL(s05 >> 提示符,输入 q / exit 退出)。文档建议的三个观察性 prompt:
Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard—— 期望行为是先todo_write列出 3 步再逐步执行。示例文件 s05_todo_write/example/hello.py 是一个 6 行的小脚本(greet函数 + 模块级调用),正好缺类型标注、docstring 和if __name__ == "__main__"守卫。Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.pyReview Python files under s05_todo_write/example and fix any style issues
观察重点:第一次工具调用是不是 todo_write?列了几步?执行过程中状态是否按 pending → in_progress → completed 迁移?终端里黄色的 ## Current Tasks 面板会实时回显这份状态。
边界与后续:TODO 列表管不了多大的任务
s05 的适用边界文档说得很明确:任务如果只是"多步",TODO 列表足够;但如果任务本身是一个几十个子任务的集合(比如"重构整个认证模块"),把它平铺在单一对话的 TODO 列表里,仍会被上下文淹没——列表本身也会成为上下文负担。这类场景的解法留给了下一章 s06 Subagent:把大任务拆成子任务,每个子任务交给一个拥有独立干净上下文的 Agent,避免交叉污染。
另外可以留意仓库 agents/ 目录下还保留着同一机制的早期形态(agents/s03_todo_write.py:工具名为 todo,条目字段为 id/text,reminder 触发后不清零),对照阅读能看出 s05 版本在字段命名(对齐 Anthropic 系产品惯例的 content/status)、字符串入参安全解析与 reminder 节流上的演进脉络。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00