226. 技术:HarnessX 第 5 课:给 Agent 加 todo_write,让长任务先有计划、执行中能更新状态

2026.06.25

·技术harnessx

20260625_4.webp|736

20260625_6.webp

重点

  • 第 5 课当前要做的是给 Agent 加一个计划工具:
    • 用户还是敲 hx agent "..."
    • 程序还是把 messagestools 发给 DeepSeek。
    • 模型可以在多步任务开始时调用 todo_write 写计划。
    • 后续模型也可以继续调用 todo_write 更新计划。
    • todo_write 不读文件,不写文件,不跑命令。
    • 它只把当前任务计划写进 Node.js 进程内存。
    • 计划内容交给模型生成,Harness 只负责提供工具和校验状态。
    • 工具结果仍然以 role: "tool" 回填给模型。
  • 这一课真正新增的能力不是“Agent 会做更多事”。
    • 之前它已经能 bash、读文件、写文件、编辑文件、查文件。
    • 现在它能在做多步任务前先把计划亮出来。
    • 做一步,更新一步。
    • 连续几轮忘了更新,程序会提醒它重新看计划。

一句话:

text
todo_write 让 Agent 的执行过程可见:模型写计划,Harness 执行并保存计划,再把计划结果回填给模型继续推理。

流程图

学员视角总流程

bash
你输入 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 一来一回

226. 技术:HarnessX 第 5 课:给 Agent 加 todo_write,让长任务先有计划、执行中能更新状态 图表 1

入口

第 5 课没有新增子命令。

bash
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"

入口仍然在 src/index.js

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 循环初始仍然只准备两条消息。

js
const messages = [
  { role: "system", content: SYSTEM_PROMPT },
  { role: "user", content: text },
];

第 5 课的变化在 SYSTEM_PROMPTtools

js
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 写计划。

bash
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"

发给 DeepSeek 的请求体核心长这样:

json
{
  "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 工具调用。

json
{
  "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

js
messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: "已更新 3 个待办\n## 当前任务\n[ ] 阅读 README\n[ ] 检查 src 文件\n[ ] 总结项目",
});

这一步非常重要:

  • 人看到的是终端输出。
  • 模型看到的是下一轮 messages 里的 role: "tool"
  • Agent 能继续做事,是因为模型下一轮读到了这个工具结果。

当前核心机制:TodoWrite

todo_write 的输入是完整列表。

json
{
  "todos": [
    { "content": "阅读 README", "status": "completed" },
    { "content": "检查 src 文件", "status": "in_progress" },
    { "content": "总结项目", "status": "pending" }
  ]
}

它不是这样:

json
{
  "patch": { "第二项": "in_progress" }
}

原因很简单:

  • 完整列表更容易讲清楚。
  • 当前教学版不需要做复杂状态合并。
  • 每次更新都覆盖当前内存里的 currentTodos

本地保存的是进程内存

js
let currentTodos = [];
  • 这个状态不会写进文件。
  • 进程退出就没了。
  • 第 5 课只关心“当前会话计划”,不做长期任务系统

状态只有三个:

text
pending      待处理,还没做
in_progress  进行中,正在做
completed    已完成,做完了

为了让计划更清楚,当前版本只允许一个 in_progress

js
if (inProgressCount > 1) {
  return "Error: only one todo can be in_progress";
}

这不是大模型必须遵守的自然语言约定,而是本地工具函数的硬校验。

提醒机制

模型有时会连续调别的工具,忘记更新计划。

所以循环里加了一个计数器:

js
let roundsSinceTodoWrite = 0;

每一轮工具调用结束后,代码判断本轮有没有 todo_write

js
roundsSinceTodoWrite = hasTodoWrite ? 0 : roundsSinceTodoWrite + 1;

下一轮请求前,如果已经连续 3 轮没更新计划,就追加一条提醒。

js
if (roundsSinceTodoWrite >= TODO_REMINDER_TURNS) {
  messages.push({
    role: "user",
    content: "<reminder>请更新当前待办计划,再继续执行。</reminder>",
  });
  roundsSinceTodoWrite = 0;
}

这条提醒本质上也是一条消息。

  • 它不是新的 API 字段。
  • 它不是工具结果。
  • 它就是追加到 messages 里的 role: "user"

模型下一轮看到它,通常就会重新调用 todo_write 更新任务状态。

怎么跑

20260625_5.webp

沿用第 0 课的 npm link 和 DeepSeek 配置,这里只跑第 5 课的最小完整示例。

这个示例要故意写成三步任务:

  • 阅读 README.md
  • 检查 src/*.js
  • 总结项目理解

为了稳定观察本课机制,prompt 里明确要求模型先用 todo_write 写计划。 这不是让 Harness 自动拆任务,而是让模型通过工具调用生成计划。

bash
hx agent "先用 todo_write 写一个三步计划:阅读 README.md,检查 src/*.js,然后总结你对这个项目的理解。执行过程中更新 todo 状态。"

跑起来后,第一段关键输出应该像这样:

bash
[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 作为本轮第一个工具调用。
  • 它没有读文件、没有跑命令,只写了当前任务计划。
  • 第一项是 [>],表示正在做。
  • 后两项是 [ ],表示还没做。

接下来会继续看到真实工具执行:

bash
> 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

任务推进时,模型会继续更新计划:

text
> todo_write items=3
## 当前任务
[x] 阅读 README.md
[>] 检查 src/*.js
[ ] 总结你对这个项目的理解

最后看到 Stop hook 和中文总结,就说明这一课跑通了:

text
[HOOK] Stop: turns=..., tool_calls=..., blocked=0
## 项目总结:HarnessX
...

这个示例的验收标准:

  • 第一批工具日志里必须先出现 > todo_write items=3
  • 任务内容由模型生成,不由 Harness 预先拆分。
  • todo_write 不应该触发 Allow? [y/N],因为它不执行危险动作。
  • 后续读取文件时,仍然走旧的 PreToolUse / PostToolUse hook。
  • 最终回答能基于 README 和 src/*.js 给出总结。

这一课真正要记住

  • todo_write
    • 当前会话的任务计划工具。
    • 只保存任务列表,不执行任务。
  • 完整列表
    • 每次更新都传完整 todos
    • 本地用新列表覆盖旧列表。
  • role: "tool"
    • 工具结果给模型看的接口。
    • 人看终端,模型看 messages
  • 提醒机制
    • 连续几轮没更新计划时,程序往 messages 里塞一条提醒。
    • 它把模型从闷头执行拉回计划状态。
  • 第 5 课的边界
    • 做计划,不做子代理。
    • 做内存状态,不做持久化任务系统。
    • 做纯文本终端输出,不恢复旧的 TUI。

一句话:

text
Agent 循环 = messages + tools + 本地工具函数 + 工具结果回填;todo_write 只是这个闭环里的一个计划工具。

源码

这里保留当前版本的主流程缩略代码,方便以后代码继续往后迭代时,还能回来看第 5 课到底加了什么。

代码概览

src/index.js 只负责入口。

js
if (command === "agent") {
  // 用户敲 hx agent 后,任务文本进入 runAgent。
  // todo_write 不在这里处理,它属于 Agent 循环内部工具。
  const task = rest.join(" ").trim();
  await runAgent(task);
  return;
}

src/agent-loop.js 负责把新工具告诉模型。

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"],
      },
    },
  },
];

工具分发表负责把模型返回的名字连到本地函数。

js
const TOOL_HANDLERS = {
  bash: runBashTool,
  read_file: runReadFile,
  write_file: runWriteFile,
  edit_file: runEditFile,
  glob: runGlob,
  todo_write: runTodoWrite,
};

代码细分

Agent 循环里先准备上下文。

js
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 消息追加回上下文。

js
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;
}

有工具调用时,逐个执行。

js
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() 仍然是通用分发器。

js
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() 只处理内存计划。

js
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()}`;
}

最后把状态渲染成普通文本。

js
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。