

重点
- 第 5 课当前要做的是给 Agent 加一个计划工具:
- 用户还是敲
hx agent "..."。 - 程序还是把
messages和tools发给 DeepSeek。 - 模型可以在多步任务开始时调用
todo_write写计划。 - 后续模型也可以继续调用
todo_write更新计划。 todo_write不读文件,不写文件,不跑命令。- 它只把当前任务计划写进 Node.js 进程内存。
- 计划内容交给模型生成,Harness 只负责提供工具和校验状态。
- 工具结果仍然以
role: "tool"回填给模型。
- 用户还是敲
- 这一课真正新增的能力不是“Agent 会做更多事”。
- 之前它已经能
bash、读文件、写文件、编辑文件、查文件。 - 现在它能在做多步任务前先把计划亮出来。
- 做一步,更新一步。
- 连续几轮忘了更新,程序会提醒它重新看计划。
- 之前它已经能
一句话:
todo_write 让 Agent 的执行过程可见:模型写计划,Harness 执行并保存计划,再把计划结果回填给模型继续推理。流程图
学员视角总流程
你输入 hx agent "先用 todo_write 写计划,再检查 README 和 src,然后总结项目"
|
v
Node.js 程序进入 runAgent()
|
v
消息列表 = [系统消息, 用户消息]
工具列表 = [bash, read_file, write_file, edit_file, glob, todo_write] # todo_write 是这次添加的
|
v
发给 DeepSeek
|
v
DeepSeek 返回 tool_calls:todo_write
|
v
HarnessX 本地执行 runTodoWrite()
|
+--> 人看到:> todo_write items=3
|
+--> 模型看到:工具结果消息,内容是“已更新 3 个待办”
|
v
下一轮 DeepSeek 读到 todo 结果,继续调用 read_file / glob / bash
|
v
任务推进时,再调用 todo_write 更新:待处理 / 进行中 / 已完成
|
v
没有工具调用时,输出最终中文结论HTTP 一来一回
入口
第 5 课没有新增子命令。
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"入口仍然在 src/index.js:
if (command === "agent") {
// 第 5 课只是给 Agent 循环加 todo_write。
// 用户命令入口不变,仍然把自然语言任务交给 runAgent。
const task = rest.join(" ").trim();
await runAgent(task);
return;
}- 这里要注意:
hx agent不知道todo_write的细节。hx agent只负责把任务文本交给 Agent 循环。- 真正决定 “有哪些工具、怎么循环、怎么回填” 的地方在
src/agent-loop.js。
发出去的是什么
Agent 循环初始仍然只准备两条消息。
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: text },
];第 5 课的变化在 SYSTEM_PROMPT 和 tools。
const SYSTEM_PROMPT = [
`你是运行在 ${process.cwd()} 的编程 Agent。`,
"请使用工具完成用户任务。",
"凡是需要检查、列举、读取多个文件、修改文件、排查问题,或预计需要两次及以上工具调用的任务,必须先调用 todo_write 写计划。",
"执行过程中要用 todo_write 把任务状态更新为 pending、in_progress、completed。",
"如果简单查询只需要一次工具调用,可以跳过 todo_write。",
].join("\n");- 这几句是在告诉模型:
- 任务复杂时,先写计划。
- 做事过程中,继续更新计划。
- 简单任务可以跳过计划。
这里有一个边界要讲清楚:
todo_write的内容应该交给模型生成。- Harness 不应该把用户输入按逗号拆开,自己伪造一份计划。
- 否则这节课教出来的就不是 Agent 计划工具,而是框架里硬编码的演示逻辑。
所以本课的稳定演示方式是:在示例 prompt 里明确要求模型先用 todo_write 写计划。
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"发给 DeepSeek 的请求体核心长这样:
{
"model": "deepseek-chat",
"messages": [
{ "role": "system", "content": "你是运行在当前目录的编程 Agent..." },
{ "role": "user", "content": "先用 todo_write 写一个三步计划..." }
],
"stream": false,
"tools": [
{ "type": "function", "function": { "name": "bash" } },
{ "type": "function", "function": { "name": "read_file" } },
{ "type": "function", "function": { "name": "write_file" } },
{ "type": "function", "function": { "name": "edit_file" } },
{ "type": "function", "function": { "name": "glob" } },
{ "type": "function", "function": { "name": "todo_write" } }
],
"tool_choice": "auto"
}tool_choice: "auto" 的意思是:
- 模型自己判断要不要调工具。
- 要调哪个工具。
- 调工具时参数应该是什么 JSON。
返回的是什么
在这个演示命令里,模型应该先返回一条 todo_write 工具调用。
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "todo_write",
"arguments": "{\"todos\":[{\"content\":\"阅读 README\",\"status\":\"pending\"},{\"content\":\"检查 src 文件\",\"status\":\"pending\"},{\"content\":\"总结项目\",\"status\":\"pending\"}]}"
}
}
]
}这里最关键的是两层:
function.name- 值是
todo_write。 - 本地分发器会用这个名字查
TOOL_HANDLERS。
- 值是
function.arguments- 它是一个 JSON 字符串。
- 本地代码会
JSON.parse()成 JavaScript 对象。 - 然后会在终端上渲染出来这个任务列表。
- 这份任务列表来自模型,不是 Harness 从用户输入里拆出来的。
本地执行后,会把结果塞回 messages。
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: "已更新 3 个待办\n## 当前任务\n[ ] 阅读 README\n[ ] 检查 src 文件\n[ ] 总结项目",
});这一步非常重要:
- 人看到的是终端输出。
- 模型看到的是下一轮
messages里的role: "tool"。 - Agent 能继续做事,是因为模型下一轮读到了这个工具结果。
当前核心机制:TodoWrite
todo_write 的输入是完整列表。
{
"todos": [
{ "content": "阅读 README", "status": "completed" },
{ "content": "检查 src 文件", "status": "in_progress" },
{ "content": "总结项目", "status": "pending" }
]
}它不是这样:
{
"patch": { "第二项": "in_progress" }
}原因很简单:
- 完整列表更容易讲清楚。
- 当前教学版不需要做复杂状态合并。
- 每次更新都覆盖当前内存里的
currentTodos。
本地保存的是进程内存:
let currentTodos = [];- 这个状态不会写进文件。
- 进程退出就没了。
- 第 5 课只关心“当前会话计划”,不做长期任务系统。
状态只有三个:
pending 待处理,还没做
in_progress 进行中,正在做
completed 已完成,做完了为了让计划更清楚,当前版本只允许一个 in_progress。
if (inProgressCount > 1) {
return "Error: only one todo can be in_progress";
}这不是大模型必须遵守的自然语言约定,而是本地工具函数的硬校验。
提醒机制
模型有时会连续调别的工具,忘记更新计划。
所以循环里加了一个计数器:
let roundsSinceTodoWrite = 0;每一轮工具调用结束后,代码判断本轮有没有 todo_write。
roundsSinceTodoWrite = hasTodoWrite ? 0 : roundsSinceTodoWrite + 1;下一轮请求前,如果已经连续 3 轮没更新计划,就追加一条提醒。
if (roundsSinceTodoWrite >= TODO_REMINDER_TURNS) {
messages.push({
role: "user",
content: "<reminder>请更新当前待办计划,再继续执行。</reminder>",
});
roundsSinceTodoWrite = 0;
}这条提醒本质上也是一条消息。
- 它不是新的 API 字段。
- 它不是工具结果。
- 它就是追加到
messages里的role: "user"。
模型下一轮看到它,通常就会重新调用 todo_write 更新任务状态。
怎么跑

沿用第 0 课的 npm link 和 DeepSeek 配置,这里只跑第 5 课的最小完整示例。
这个示例要故意写成三步任务:
- 阅读
README.md - 检查
src/*.js - 总结项目理解
为了稳定观察本课机制,prompt 里明确要求模型先用 todo_write 写计划。 这不是让 Harness 自动拆任务,而是让模型通过工具调用生成计划。
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"跑起来后,第一段关键输出应该像这样:
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
> todo_write items=3
[HOOK] PreToolUse todo_write(...)
[HOOK] PostToolUse todo_write output_chars=...
已更新 3 个待办
## 当前任务
[>] 阅读 README.md
[ ] 检查 src/*.js
[ ] 总结你对这个项目的理解这段输出说明三件事:
- 模型按用户要求,把
todo_write作为本轮第一个工具调用。 - 它没有读文件、没有跑命令,只写了当前任务计划。
- 第一项是
[>],表示正在做。 - 后两项是
[ ],表示还没做。
接下来会继续看到真实工具执行:
> read_file path=README.md limit=all
> glob pattern=src/*.js
> read_file path=src/index.js limit=all
> read_file path=src/deepseek.js limit=all
> read_file path=src/agent-loop.js limit=all任务推进时,模型会继续更新计划:
> todo_write items=3
## 当前任务
[x] 阅读 README.md
[>] 检查 src/*.js
[ ] 总结你对这个项目的理解最后看到 Stop hook 和中文总结,就说明这一课跑通了:
[HOOK] Stop: turns=..., tool_calls=..., blocked=0
## 项目总结:HarnessX
...这个示例的验收标准:
- 第一批工具日志里必须先出现
> todo_write items=3。 - 任务内容由模型生成,不由 Harness 预先拆分。
todo_write不应该触发Allow? [y/N],因为它不执行危险动作。- 后续读取文件时,仍然走旧的
PreToolUse/PostToolUsehook。 - 最终回答能基于 README 和
src/*.js给出总结。
这一课真正要记住
todo_write- 当前会话的任务计划工具。
- 只保存任务列表,不执行任务。
- 完整列表
- 每次更新都传完整
todos。 - 本地用新列表覆盖旧列表。
- 每次更新都传完整
role: "tool"- 工具结果给模型看的接口。
- 人看终端,模型看
messages。
- 提醒机制
- 连续几轮没更新计划时,程序往
messages里塞一条提醒。 - 它把模型从闷头执行拉回计划状态。
- 连续几轮没更新计划时,程序往
- 第 5 课的边界
- 做计划,不做子代理。
- 做内存状态,不做持久化任务系统。
- 做纯文本终端输出,不恢复旧的 TUI。
一句话:
Agent 循环 = messages + tools + 本地工具函数 + 工具结果回填;todo_write 只是这个闭环里的一个计划工具。源码
这里保留当前版本的主流程缩略代码,方便以后代码继续往后迭代时,还能回来看第 5 课到底加了什么。
代码概览
src/index.js 只负责入口。
if (command === "agent") {
// 用户敲 hx agent 后,任务文本进入 runAgent。
// todo_write 不在这里处理,它属于 Agent 循环内部工具。
const task = rest.join(" ").trim();
await runAgent(task);
return;
}src/agent-loop.js 负责把新工具告诉模型。
const TOOL_DEFINITIONS = [
// 前面还有 bash / read_file / write_file / edit_file / glob
{
type: "function",
function: {
name: "todo_write",
// 这段描述会发给模型,也会影响模型什么时候选择这个工具。
description: "创建或更新当前 Agent 会话里的内存任务列表...",
parameters: {
type: "object",
properties: {
todos: {
type: "array",
items: {
type: "object",
properties: {
content: { type: "string" },
status: {
type: "string",
enum: ["pending", "in_progress", "completed"],
},
},
required: ["content", "status"],
},
},
},
required: ["todos"],
},
},
},
];工具分发表负责把模型返回的名字连到本地函数。
const TOOL_HANDLERS = {
bash: runBashTool,
read_file: runReadFile,
write_file: runWriteFile,
edit_file: runEditFile,
glob: runGlob,
todo_write: runTodoWrite,
};代码细分
Agent 循环里先准备上下文。
export async function runAgent(task) {
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: task.trim() },
];
let roundsSinceTodoWrite = 0;
for (let turn = 1; turn <= AGENT_MAX_TURNS; turn += 1) {
// 如果模型连续几轮没更新计划,就先塞一条提醒给下一轮模型。
if (roundsSinceTodoWrite >= TODO_REMINDER_TURNS) {
messages.push({ role: "user", content: TODO_REMINDER_MESSAGE });
roundsSinceTodoWrite = 0;
}
const message = await callDeepSeekMessage(messages, {
tools: TOOL_DEFINITIONS,
tool_choice: "auto",
});这里没有“初始 TODO 生成器”。
- Harness 提供工具定义。
- 模型决定是否调用
todo_write。 - 模型生成完整
todos列表。 - Harness 只负责执行、校验、保存和回填工具结果。
模型返回后,先把 assistant 消息追加回上下文。
messages.push(normalizeAssistantMessage(message));
const toolCalls = message.tool_calls || [];
if (toolCalls.length === 0) {
// 没有工具调用,说明模型认为任务已经完成。
// Stop hook 做收尾统计,最终文本打印给用户。
await triggerHooks("Stop", messages, stats);
console.log(message.content);
return;
}有工具调用时,逐个执行。
let hasTodoWrite = false;
for (const toolCall of toolCalls) {
if (toolCall.function?.name === "todo_write") {
// 本轮只要碰过 todo_write,就认为计划刚更新过。
hasTodoWrite = true;
}
const output = await runToolCall(toolCall, stats);
// 所有工具结果都作为 tool 消息回填。
// todo_write 也一样,没有特殊通道。
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}
roundsSinceTodoWrite = hasTodoWrite ? 0 : roundsSinceTodoWrite + 1;runToolCall() 仍然是通用分发器。
async function runToolCall(toolCall, stats) {
const toolName = toolCall.function?.name;
const handler = TOOL_HANDLERS[toolName];
// DeepSeek 返回的是 JSON 字符串,本地要先解析成对象。
const input = JSON.parse(toolCall.function.arguments || "{}");
console.log(renderToolCall(toolName, input));
// PreToolUse hook 仍然会触发。
// todo_write 不命中权限规则,所以不会要求用户确认。
const blocked = await triggerHooks("PreToolUse", toolName, input, toolCall);
if (blocked) {
return "Permission denied.";
}
const output = await handler(input);
await triggerHooks("PostToolUse", toolName, input, output, toolCall);
return output;
}runTodoWrite() 只处理内存计划。
const TODO_STATUS = new Set(["pending", "in_progress", "completed"]);
let currentTodos = [];
function runTodoWrite(input) {
const todos = input.todos;
if (!Array.isArray(todos)) {
return "错误:todo_write.todos 必须是数组";
}
const validated = [];
let inProgressCount = 0;
for (const todo of todos) {
// content 是人能看懂的任务描述。
const content = typeof todo.content === "string" ? todo.content.trim() : "";
if (!content) {
return "错误:todo_write.content 必须是非空字符串";
}
// status 是程序能校验的状态。
const status = todo.status ?? "pending";
if (!TODO_STATUS.has(status)) {
return `错误:无效的 todo 状态 ${status}`;
}
if (status === "in_progress") {
inProgressCount += 1;
}
validated.push({ content, status });
}
if (inProgressCount > 1) {
return "错误:最多只能有一个 todo 是 in_progress";
}
// 教学版直接用完整列表覆盖当前内存状态。
currentTodos = validated;
return `已更新 ${currentTodos.length} 个待办\n${renderCurrentTodos()}`;
}最后把状态渲染成普通文本。
function renderCurrentTodos() {
const lines = ["## 当前任务"];
for (const todo of currentTodos) {
// 这段文本既给人看,也给模型下一轮看。
lines.push(`${markerForStatus(todo.status)} ${todo.content}`);
}
return lines.join("\n");
}
function markerForStatus(status) {
switch (status) {
case "pending":
return "[ ]";
case "in_progress":
return "[>]";
case "completed":
return "[x]";
default:
return "[?]";
}
}最后:这一课的通用流程
第 5 课跑通以后,后面遇到长任务就按这个顺序看:
- 用户提出任务。
- 模型看到
todo_write工具定义。 - 模型生成完整
todos列表。 - Harness 执行
runTodoWrite(),把计划保存到当前进程内存。 - Harness 把计划结果作为
role: "tool"回填。 - 模型继续读文件、查文件、执行命令,并在推进过程中更新计划。
这里最重要的判断是:计划内容交给模型,工具闭环交给 Harness。