- Published on
Agent Harness 工程:TodoWrite 与任务计划
- Authors

- Name
- XiaoLeiJun
Agent Harness 工程:TodoWrite 与任务计划
上一篇我们给 Agent Harness 加了 Hook 机制,把权限、审计、结果截断这类横切逻辑从主循环里拆了出去。到这里,Harness 的结构已经比较清楚:
TOOLS告诉模型有哪些工具;TOOL_HANDLERS负责把工具名映射到真实函数;- 权限策略和 Hook 负责控制工具调用前后的行为;
- Agent Loop 只维护模型调用、工具执行和消息历史。
现在继续往“能完成复杂任务”的方向走。
当任务只有一步时,Agent 直接调用工具就够了。比如“读一下 README”,模型调用一次 read_file,拿到结果,再回复用户。
但任务一复杂,问题就会变得明显:Agent 很容易执行到后面忘记自己原本要做什么,也容易忘记已经做完了哪些步骤。
这并不神秘。人做复杂事情时也一样。如果没有清单,写着写着代码可能就忘了还有测试没跑;修着修着 bug 可能就忘了最开始的复现路径。解决办法也很朴素:先列计划,再按步骤执行,每完成一步就更新状态。
这一篇我们就给 Agent 加上这个能力:TodoWrite。
为什么 messages 不够
前几篇里,我们已经一直在维护 messages:
- 用户提出任务;
- 模型返回工具调用;
- Harness 执行工具;
- 把工具结果作为
tool消息写回; - 模型继续思考下一步。
理论上,模型可以从完整的 messages 里读出“我做过什么、接下来该做什么”。但在真实任务里,这种方式并不可靠。
原因主要有三个。
第一,messages 是流水账,不是任务状态。里面混着用户需求、模型回复、工具调用参数、工具结果、错误信息和中间解释。模型要从一长串历史里恢复当前进度,成本很高。
第二,工具结果会越来越多。比如读文件、跑测试、搜索代码,都会把大量文本塞进上下文。真正重要的可能只是“已读过配置文件”“测试失败在某个断言”,但这些事实会淹没在原始输出里。
第三,模型的注意力会漂移。上下文越长,越容易出现“前面说过但后面忘了”的情况。尤其是多轮任务里,用户可能中途补充要求,工具可能失败,计划也可能需要调整。
所以我们需要一份更稳定、更紧凑的任务状态。
TodoWrite 的作用就是把“当前要做什么”从流水账里抽出来,变成一份结构化清单。
TodoWrite 不替代 Agent Loop,它只是把任务计划变成显式状态,让模型每一步都能对齐当前进度。
TodoWrite 是什么
TodoWrite 本质上也是一个工具。
只不过它不是用来读文件、写文件、执行命令,而是用来维护 Harness 内部的任务清单。
它接收一组 todo,每个 todo 至少包含两类信息:
| 字段 | 含义 |
|---|---|
content | 这一步要做什么 |
status | 当前状态,通常是三种状态之一 |
id | 可选但推荐,方便稳定地更新某一项 |
status 可以先保持简单:
| 状态 | 含义 |
|---|---|
pending | 还没开始 |
in_progress | 正在做这一项 |
completed | 已经完成并确认 |
这里有一个很重要的细节:TodoWrite 应该更新完整清单,而不是只更新单个字段。
也就是说,模型每次调用 TodoWrite,都传入当前完整的 todos 快照。这样 Harness 不需要猜“这次是新增、删除还是修改”,模型也能明确表达它眼里的当前计划。
这和前几篇的工具调用理念一致:工具参数要尽量清楚,Harness 尽量做确定性的解析和校验。
先定义任务状态
先写一个最小实现。
为了教学清楚,这里先用内存变量保存当前清单:
from typing import Literal, TypedDict
TodoStatus = Literal["pending", "in_progress", "completed"]
class TodoItem(TypedDict):
id: str
content: str
status: TodoStatus
CURRENT_TODOS: list[TodoItem] = []
TODO_STATUSES = {"pending", "in_progress", "completed"}
真实项目里,CURRENT_TODOS 不一定放在全局变量里。
如果你的 Harness 是命令行程序,可以把它挂在当前 session 上;如果是 Web 服务,可以挂在 conversation、run 或 task 记录上;如果支持恢复任务,就应该持久化到数据库或本地状态文件里。
这一篇先用全局变量,是为了把核心机制讲清楚。
实现 run_todo_write
接下来实现工具函数。
它要做三件事:
- 校验模型传入的 todos;
- 更新当前任务清单;
- 返回一段简短结果,写回
messages,让模型知道更新成功。
def render_todos() -> str:
if not CURRENT_TODOS:
return "No active todos."
icons = {
"pending": " ",
"in_progress": ">",
"completed": "x",
}
lines = ["Current todos:"]
for todo in CURRENT_TODOS:
icon = icons[todo["status"]]
lines.append(f"[{icon}] {todo['id']} - {todo['content']}")
return "\n".join(lines)
def run_todo_write(todos: list[dict]) -> str:
global CURRENT_TODOS
if not isinstance(todos, list):
return "Error: todos must be a list."
normalized: list[TodoItem] = []
seen_ids: set[str] = set()
in_progress_count = 0
for index, item in enumerate(todos, start=1):
if not isinstance(item, dict):
return f"Error: todo #{index} must be an object."
todo_id = str(item.get("id") or f"todo-{index}").strip()
content = str(item.get("content") or "").strip()
status = str(item.get("status") or "pending").strip()
if not todo_id:
return f"Error: todo #{index} id is empty."
if todo_id in seen_ids:
return f"Error: duplicate todo id - {todo_id}"
if not content:
return f"Error: todo #{index} content is empty."
if status not in TODO_STATUSES:
return f"Error: invalid todo status - {status}"
if status == "in_progress":
in_progress_count += 1
seen_ids.add(todo_id)
normalized.append(
{
"id": todo_id,
"content": content,
"status": status, # type: ignore[typeddict-item]
}
)
if in_progress_count > 1:
return "Error: only one todo can be in_progress at a time."
CURRENT_TODOS = normalized
return render_todos()
这里有几个细节值得保留。
第一,id 最好稳定。没有 id 时可以自动生成,但更推荐让模型显式传入,比如 understand-requirement、edit-code、run-tests。稳定 id 方便后续更新,不容易因为列表顺序变化而改错项。
第二,同一时间最多只允许一个 in_progress。这不是绝对规则,但对单线程 Agent 来说很有用。它能逼迫模型说清楚“我现在正在做哪一步”。
第三,completed 不等于“我觉得差不多了”。只有当这一项真的做完,并且必要时验证过,才应该标记完成。比如“运行测试”这一项,应该在测试命令执行成功之后再完成。
第四,工具返回的是清单摘要,而不是长篇解释。TodoWrite 的结果会写回 messages,所以它要短、稳定、容易被模型继续读取。
把 TodoWrite 暴露给模型
工具函数写好了,还要把它加入 TOOLS。
这里继续沿用前几篇的 Chat Completions 工具格式:
TODO_WRITE_TOOL = {
"type": "function",
"function": {
"name": "run_todo_write",
"description": (
"Create or update the full task todo list. "
"Use it before starting a multi-step task, "
"and update it whenever task progress changes."
),
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable id for this todo item.",
},
"content": {
"type": "string",
"description": "A concrete task step.",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
},
},
"required": ["id", "content", "status"],
},
}
},
"required": ["todos"],
},
},
}
然后把它追加到原来的工具列表里:
TOOLS = [
BASH_TOOL,
LIST_DIR_TOOL,
READ_FILE_TOOL,
WRITE_FILE_TOOL,
EDIT_FILE_TOOL,
TODO_WRITE_TOOL,
]
这里我把工具定义写成 BASH_TOOL、READ_FILE_TOOL 这种变量,是为了让文章更短。前几篇已经贴过完整的文件工具 schema,本篇重点是 TodoWrite。
注册到 TOOL_HANDLERS
接着把真实函数放进处理器注册表:
TOOL_HANDLERS: dict[str, Callable[..., str]] = {
"bash": bash,
"list_dir": list_dir,
"read_file": read_file,
"write_file": write_file,
"edit_file": edit_file,
"run_todo_write": run_todo_write,
}
这一步之后,Agent Loop 本身不需要改。
前几篇已经把工具执行路径抽象成了:
- 模型返回
tool_calls; - Harness 解析工具名和参数;
run_tool_call_with_hooks()执行 Hook;run_tool_call()从TOOL_HANDLERS找函数;- 工具结果写回
messages。
TodoWrite 只是多了一个工具,所以可以直接复用这条路径。
TodoWrite 把“任务进度”放在独立状态里;messages 仍然保存对话和工具结果,两者互相补充。
TodoWrite 和权限 Hook 的关系
上一章引入了权限 Hook,那么 TodoWrite 要不要走权限?
我的建议是:走同一条工具执行路径,但默认放行。
因为 TodoWrite 只更新 Harness 内部的任务状态,不读写用户文件,也不执行外部命令,风险比 write_file、edit_file、bash 小很多。
可以在权限策略里加一条:
def check_permission(tool_name: str, arguments: dict[str, Any]) -> PermissionResult:
if tool_name == "run_todo_write":
return PermissionResult(
"allow",
"todo write only updates internal task state",
)
if tool_name in {"write_file", "edit_file"}:
return PermissionResult("ask", f"{tool_name} modifies files")
# 其他规则沿用前一篇
这样做的好处是结构统一:所有模型工具调用都经过 Hook,只是不同工具的权限策略不同。
同时,审计 Hook 仍然可以记录 TodoWrite。以后你回放一次任务时,不只能看到 Agent 调用了哪些文件工具,也能看到它什么时候制定计划、什么时候更新进度。
用系统提示词约束使用方式
仅仅把工具暴露给模型还不够。模型需要知道什么时候该用 TodoWrite、怎么更新 TodoWrite。
所以还要补一段系统提示词:
SYSTEM_PROMPT = f"""
你是一个编程助手,工作在当前目录 {WORKDIR}。
你可以使用工具来读取文件、修改文件、执行安全命令,并维护任务清单。
工作流程要求:
1. 如果用户任务需要多个步骤才能完成,先调用 run_todo_write 创建待办清单。
2. 每个 todo 都应该是可执行、可验证的具体步骤,不要写成空泛目标。
3. 开始执行某一项前,把它更新为 in_progress。
4. 完成并验证某一项后,把它更新为 completed。
5. 同一时间最多只能有一个 in_progress。
6. 如果执行过程中发现新步骤、删除无效步骤,及时调用 run_todo_write 更新完整清单。
7. 简单的一步任务可以不使用待办清单。
"""
这段提示词不是安全边界,真正的安全边界仍然在 Harness 里。
它的作用是给模型一个工作习惯:复杂任务先计划,执行中更新进度,最后基于真实完成情况总结。
一次任务会怎么跑
假设用户说:
帮我给博客新增一个搜索框,并跑一下测试。
有了 TodoWrite 后,Agent 的行为应该类似这样。
第一步,先建立计划:
{
"todos": [
{
"id": "inspect-project",
"content": "查看项目结构,找到博客列表和搜索相关代码",
"status": "in_progress"
},
{
"id": "implement-search",
"content": "实现搜索框和过滤逻辑",
"status": "pending"
},
{
"id": "run-tests",
"content": "运行测试或构建命令验证修改",
"status": "pending"
},
{
"id": "summarize",
"content": "总结修改内容和验证结果",
"status": "pending"
}
]
}
然后它调用 list_dir、read_file 查看项目结构。完成后更新清单:
{
"todos": [
{
"id": "inspect-project",
"content": "查看项目结构,找到博客列表和搜索相关代码",
"status": "completed"
},
{
"id": "implement-search",
"content": "实现搜索框和过滤逻辑",
"status": "in_progress"
},
{
"id": "run-tests",
"content": "运行测试或构建命令验证修改",
"status": "pending"
},
{
"id": "summarize",
"content": "总结修改内容和验证结果",
"status": "pending"
}
]
}
再执行 edit_file 或 write_file。如果测试失败,清单也可以调整:
{
"todos": [
{
"id": "inspect-project",
"content": "查看项目结构,找到博客列表和搜索相关代码",
"status": "completed"
},
{
"id": "implement-search",
"content": "实现搜索框和过滤逻辑",
"status": "completed"
},
{
"id": "fix-test-failure",
"content": "根据测试失败信息修复搜索逻辑",
"status": "in_progress"
},
{
"id": "run-tests",
"content": "重新运行测试或构建命令验证修改",
"status": "pending"
},
{
"id": "summarize",
"content": "总结修改内容和验证结果",
"status": "pending"
}
]
}
这就是 TodoWrite 的价值:计划不是一次性的。它会随着任务推进不断修正。
TodoWrite 改善了什么
TodoWrite 看起来只是一个列表,但它会明显改善 Agent 的行为。
第一,减少遗忘。 模型不用每次从长上下文里重新推断当前进度,只要看当前 todo 状态,就知道下一步该做什么。
第二,让用户可观察。 用户能看到 Agent 当前计划和执行进度,而不是只能看见一堆工具调用输出。
第三,方便中断和恢复。 如果任务执行到一半被打断,恢复时可以先读当前 todos,而不是完全依赖历史消息。
第四,减少“假完成”。 如果提示词和权限策略都要求完成后更新 todo,模型更容易在最终总结前检查是否还有 pending 或 in_progress。
第五,给后续能力打基础。 后面要做子代理、上下文压缩、任务持久化,都需要有一个明确的任务状态。TodoWrite 是最小的一步。
什么时候不要用 TodoWrite
TodoWrite 不是所有任务都要用。
如果用户只是问“这个函数是什么意思”,或者让 Agent 读取一个文件、解释一段代码,强行创建待办清单反而会显得啰嗦。
一个简单判断是:
- 需要读多个文件,适合用;
- 需要修改代码并验证,适合用;
- 需要排查错误,适合用;
- 任务可能超过两三步,适合用;
- 单次问答、单次读取、单次解释,通常不用。
另外,todo 的粒度也不要太细。
不要把“打开文件”“读取文件第 1 行”“读取文件第 2 行”拆成 todo。Todo 应该描述有意义的阶段,比如“理解现有实现”“修改登录逻辑”“运行测试验证”。
小结
这一篇在前几章的基础上,为 Agent Harness 增加了 TodoWrite:
- 用
CURRENT_TODOS保存当前任务清单; - 用
run_todo_write()创建和更新完整 todo 快照; - 把
run_todo_write加入TOOLS,让模型知道可以维护任务计划; - 把它加入
TOOL_HANDLERS,复用原来的工具分发路径; - 在权限策略里默认放行,但仍然保留 Hook 审计;
- 用系统提示词约束模型:复杂任务先列计划,执行中持续更新,完成后再总结。
到这里,Agent 已经不只是“能调用工具”,而是开始具备一种更像工作流的能力:先拆任务,再执行,再根据结果更新计划。
下一篇可以继续往更复杂的协作结构走:Subagent。当一个任务太大,单个 Agent 很难同时兼顾阅读、实现、测试和总结时,就可以把一部分工作交给子代理,让主 Agent 负责规划、分派和整合结果。