learn-claude-code s03: TodoWrite 待办写入——用「单一 in_progress + nag reminder」让 Agent 在多步任务中不偏航
本篇技术指南以 learn-claude-code 课程第 s03 章 TodoWrite 文档 为主体,完整拆解「规划层」这一 harness 能力的实现:一个带状态校验的 TodoManager、一个并入 dispatch map 的 todo 工具,以及一个连续 3 轮不更新计划就注入 <reminder> 的 nag reminder 机制。读完你可以从零复制这套模式,让任何 agent loop 在多步任务中保持顺序聚焦、可观测、可问责,并了解后续 s05 章节对该机制的演进与测试验证方式。
问题:多步任务中模型为什么会丢失进度
s03 文档把问题描述得很直接:多步任务中,模型会丢失进度——重复做过的事、跳步、跑偏,而且对话越长越严重。原因是工具结果不断填满上下文,系统提示的影响力被逐渐稀释。文档给出的典型场景是:一个 10 步重构任务,模型做完第 1–3 步就开始即兴发挥,因为第 4–10 步已经被挤出注意力窗口了。
这正是 harness 工程要解决的问题。按照仓库 README 的核心主张——"Agency 来自模型,harness 是载具"——规划层属于典型的 harness 能力:它不替模型做决策,而是给模型一个外置的、可被系统持续问责的状态容器。s03 的一句话定位是:
"没有计划的 agent 走哪算哪" —— 先列步骤再动手。 Harness 层: 规划 —— 让模型不偏航,但不替它画航线。
方案总览:TodoManager + dispatch 分发 + nag 注入
s03 文档给出的整体结构如下:
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
三个关键构件:
- TodoManager——进程内单例,存储带
pending / in_progress / completed状态的任务项,同一时间只允许一个in_progress; todo工具——和 bash/read/write/edit 一样注册进TOOL_HANDLERSdispatch map,对 LLM 而言它只是一个"只改计划、不做执行"的工具;- nag reminder——agent loop 中维护
rounds_since_todo计数器,模型连续 3 轮以上不调用todo时,向工具结果中注入<reminder>Update your todos.</reminder>。
文档点出了两个设计意图:"同时只能有一个 in_progress" 强制顺序聚焦;nag reminder 制造问责压力——你不更新计划,系统就追着你问。
机制一:TodoManager——结构化的规划状态
文档中的最小实现示意如下:
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
仓库中 s03 章节的完整实现位于 agents/s03_todo_write.py,比文档伪码多了两层防御,值得逐条看:
class TodoManager:
def __init__(self):
self.items = []
def update(self, items: list) -> str:
if len(items) > 20:
raise ValueError("Max 20 todos allowed")
validated = []
in_progress_count = 0
for i, item in enumerate(items):
text = str(item.get("text", "")).strip()
status = str(item.get("status", "pending")).lower()
item_id = str(item.get("id", str(i + 1)))
if not text:
raise ValueError(f"Item {item_id}: text required")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Item {item_id}: invalid status '{status}'")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item_id, "text": text, "status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress at a time")
self.items = validated
return self.render()
def render(self) -> str:
if not self.items:
return "No todos."
lines = []
for item in self.items:
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]]
lines.append(f"{marker} #{item['id']}: {item['text']}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
结合源码可以归纳出这条链路上的校验参数与行为:
| 校验点 | 规则 | 违反时的行为 |
|---|---|---|
| 列表长度 | 单次更新最多 20 个 todo | 抛 ValueError("Max 20 todos allowed") |
text 字段 |
非空(strip() 后) |
抛 ValueError(f"Item {item_id}: text required") |
status 取值 |
仅 pending / in_progress / completed,且强制 lower() 归一 |
抛 ValueError(...invalid status...) |
in_progress 数量 |
至多 1 个 | 抛 ValueError("Only one task can be in_progress at a time") |
| 状态替换 | 校验全部通过后才整体替换 self.items |
非法更新不会污染已有状态 |
注意最后一点:校验在前、赋值在后。整个列表先完成遍历和计数,只有全部通过才会 self.items = validated。这意味着模型的一次错误更新(比如同时写了两个 in_progress)会被拒绝且原计划原封不动——规划状态不会被"半途失败的写操作"打坏。
render() 的返回值是喂给模型的 tool_result 正文:[ ] / [>] / [x] 三个标记加 #id 前缀,尾部附 (done/total completed) 进度统计。这一设计让每次 todo 调用后的结果本身就是一份进度快照——模型在后续每轮都能从工具结果里看到当前计划的完整状态,而不必依赖被稀释的系统提示。这也是 s03 文档"agent 可以自己追踪进度,而我(用户)也看得到"这一关键洞察的落点(见 agents/s03_todo_write.py 文件头注释)。
机制二:todo 工具像其他工具一样进入 dispatch map
s03 文档强调:todo 工具不改变 agent loop 的结构,它和 bash、read_file 一样加入 dispatch map。源码印证了这一点(agents/s03_todo_write.py):
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"todo": lambda **kw: TODO.update(kw["items"]), # 唯一的"纯状态"工具
}
对应的工具定义中,todo 的 schema 约束与 TodoManager 的校验一一对应:
{"name": "todo", "description": "Update task list. Track progress on multi-step tasks.",
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"text": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
},
"required": ["id", "text", "status"],
}}}}, "required": ["items"]},
与 s02 的工具集相比(见 agents/s02_tool_use.py),s03 的工具数从 4 个(bash/read/write/edit)变为 5 个;todo 是唯一不碰文件、不执行命令的工具——它只更新规划状态,实际工作仍由既有工具完成。
配套地,系统提示词也被改写以引导这个行为(agents/s03_todo_write.py):
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
Prefer tools over prose."""
"Mark in_progress before starting, completed when done" 规定了标准操作节奏:先建计划(全 pending)→ 选一步置为 in_progress → 完成后置为 completed → 取下一个 pending。文档没有给 agent 增加任何执行能力,增加的是规划能力。
机制三:nag reminder——不更新计划的问责压力
文档给出的最小示意是"连续 3 轮不调用 todo 就注入提醒"。s03 的完整实现在 agent_loop 中:
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":
return
results = []
used_todo = False
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
if block.name == "todo":
used_todo = True
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>"})
messages.append({"role": "user", "content": results})
从源码结构看,有三处细节值得注意:
- 计数器按"轮"而非按"次"计算。每轮 LLM 响应中若任一
tool_use块名为todo,used_todo置真,计数器清零;否则 +1。连续 3 轮纯执行(read/write/edit/bash)后触发提醒。 - 注入位置在当前轮的工具结果批里。实现是在本轮
results末尾追加一个{"type": "text"}块,与tool_result一并作为user消息回传;而文档中的简化伪码是把 reminder 插到上一条 user 消息头部(last["content"].insert(0, ...))。两者语义等价(都是在模型下一次可见的上下文里塞入提醒),实现版避免了改动历史消息,更贴近"随结果回传"的 agent 惯例。 - 提醒不阻塞、不替换执行。
<reminder>Update your todos.</reminder>只是一句轻量文本压力,工具照常执行、任务照常推进——这正是"让模型不偏航,但不替它画航线"的工程表达。
相对 s02 的变更
完整继承 s03 文档的对照表:
| 组件 | 之前 (s02) | 之后 (s03) |
|---|---|---|
| Tools | 4 | 5(+todo) |
| 规划 | 无 | 带状态的 TodoManager |
| Nag 注入 | 无 | 3 轮后注入 <reminder> |
| Agent loop | 简单分发 | + rounds_since_todo 计数器 |
试一试:运行 s03 Agent 并观察规划行为
环境准备(requirements.txt 只依赖 3 个包):
pip install -r requirements.txt # anthropic>=0.25.0, python-dotenv>=1.0.0, pyyaml>=6.0
# 在 .env 或环境中配置模型,s03 会 load_dotenv 并读取:
# MODEL_ID=... # 必填,代码中 os.environ["MODEL_ID"]
# ANTHROPIC_BASE_URL=... # 可选,自定义端点
运行方式(agents/s03_todo_write.py 是交互式 REPL,提示符为 s03 >> ,输入 q / exit / 空行退出):
cd learn-claude-code
python agents/s03_todo_write.py
s03 文档推荐的三个测试 prompt(英文 prompt 对 LLM 效果更好,也可以用中文):
Refactor the file hello.py: add type hints, docstrings, and a main guardCreate a Python package with __init__.py, utils.py, and tests/test_utils.pyReview all Python files and fix any style issues
观察点与文档中的"试一试"意图一致:首工具调用是不是 todo?列出了几个步骤?执行过程中状态是否从 pending 走到 in_progress 再到 completed?终端里 > todo: 之后打印的 200 字符预览就是 TodoManager 的 render() 快照,可以直观看到 (1/3 completed) 这类进度。
测试验证与后续演进
仓库的测试用例 tests/test_todo_write_string_input.py 对这套机制的"后续演进版"(s05 章节,s05_todo_write/code.py)做了自动化验证,其中三个用例恰好覆盖了本文的核心不变量,可作为验收参考:
- 无效更新不替换状态(test_rejects_invalid_updates_without_replacing_state):空
content、双in_progress、超过 20 项的更新都必须返回Error:,且module.TODO.items保持上一次合法更新的内容——即上文"校验在前、赋值在后"的行为; - 第 3 轮结果批恰好追加一条 reminder(test_appends_one_reminder_to_the_third_tool_result_batch):模拟 3 轮只调
glob不调 todo 的响应流,断言前两轮 user 消息中无text块、第 3 轮结果批中出现且仅出现{"type": "text", "text": "<reminder>Update your todos.</reminder>"}; - 字符串入参不经过
eval:s05 版本把run_todo_write(todos: list | str)的入参放宽为可接受 JSON 数组字符串或 Python list 字面量字符串(先json.loads,失败再ast.literal_eval,全程无eval),测试用__import__('pathlib')...的注入串验证了安全性(s05_todo_write/code.py)。
从 s03 到 s05 的演进脉络可以概括为:todo(id/text 字段,注入位置在历史消息头部)→ todo_write(content/status 字段、maxItems: 20 写进 schema、reminder 随当轮结果批回传且触发后计数器复位、字符串入参安全解析)。如果你想继续往下读,仓库中 s04_hooks 与 s05_todo_write 两章展示了 todo 机制如何与 hook 系统共存,以及"任务大到单一 TODO 列表兜不住"之后由 subagent 接力的动机。
小结
s03 用约 40 行增量代码给 agent loop 加上了规划层:TodoManager 以"20 项上限 + 三态校验 + 单 in_progress + 全量原子替换"约束了模型写入的规划状态;todo 工具零改动地融入既有 dispatch map;rounds_since_todo 计数器与 <reminder> 注入构成了不侵入执行路径的问责机制。三者叠加回答的是同一个问题——在注意力被工具结果不断稀释的长对话里,如何低成本地把"计划"从模型的隐式状态变成 harness 里显式可查、可持续追问的对象。这正是 learn-claude-code "先列步骤再动手"这一规划层设计的完整落地。
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 StartedRust0624
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