首页
/ learn-claude-code s05 详解:TodoWrite 工具与 Reminder 机制如何赋予 Coding Agent 规划能力

learn-claude-code s05 详解:TodoWrite 工具与 Reminder 机制如何赋予 Coding Agent 规划能力

2026-09-04 20:58:46作者:董斯意

learn-claude-code 课程化构建 Claude Code 式 agent harness 的 17 章中,s05 专门解决一个普遍问题:长任务执行到一半时,Agent 会被中间结果吸走注意力而"跑偏"。本章通过一个带状态的 todo_write 工具、一套输入校验规则和一个"连续 3 轮未更新就注入提醒"的 reminder 计数器,让 Agent 先列计划再动手、边做边更新进度。读完后你会掌握:TodoManager 的完整校验与渲染实现、todo_write 如何接入既有工具分发表、reminder 在 agent loop 中的精确注入位置,以及配套的测试如何验证这些行为。

TodoWrite 机制总览:用户提示进入 LLM,todo_write 更新 TodoManager 状态,rounds_since_todo 达到 3 时注入 reminder

问题:为什么复杂任务会"做着做着就偏了"

文档给出的典型场景是:给 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)的基础上只做两件事:

  1. 新增 todo_write 工具,由 TodoManager 维护一份内存中的任务列表;
  2. 在 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.pyTodoManager 类。它持有内存列表 items,负责校验每次更新,并把渲染结果同时返回给模型和打印到终端。

输入解析:JSON 或 Python 列表字面量,但不使用 eval

update 方法同时接受 liststr 两种入参。字符串输入先尝试 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.pytest_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.pytest_rejects_invalid_updates_without_replacing_state 先写入一份合法状态 keep this,再依次投递空 content、双 in_progress、21 项三种非法更新,断言每次都返回 Error:TODO.items 未被篡改。

渲染:给模型和人类各一份视图

rendercode.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_writecode.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: 20minLength: 1TodoManager 的运行时校验是双层约束: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})

从源码结构看,有三个实现细节值得留意:

  1. 计数粒度是"轮"(一次 assistant 响应),不是"工具调用"。同一轮里模型可能并发发起多个 tool_use block,只要其中有一个是 todo_writeused_todo 即为 True,整轮清零。
  2. reminder 是"搭车"注入的:它不是独立的一轮 user 消息,而是作为 {"type": "text"} block 与第三轮的 tool_result 一起,拼进同一条 user 消息(messages.append({"role": "user", "content": results}))。这保证了对话角色交替的合法性,也让提醒与"刚完成的那批工具结果"强绑定。
  3. 触发即清零rounds_since_todo = 0),所以 reminder 最多每 3 轮出现一次,不会形成刷屏。

测试 tests/test_todo_write_string_input.pytest_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 联合类型语法)、anthropicpython-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:

  1. 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__" 守卫。
  2. Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py
  3. Review 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 节流上的演进脉络。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384