Published on

Agent Harness 工程:TodoWrite 与任务计划

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

Agent Harness 工程:TodoWrite 与任务计划

上一篇我们给 Agent Harness 加了 Hook 机制,把权限、审计、结果截断这类横切逻辑从主循环里拆了出去。到这里,Harness 的结构已经比较清楚:

  • TOOLS 告诉模型有哪些工具;
  • TOOL_HANDLERS 负责把工具名映射到真实函数;
  • 权限策略和 Hook 负责控制工具调用前后的行为;
  • Agent Loop 只维护模型调用、工具执行和消息历史。

现在继续往“能完成复杂任务”的方向走。

当任务只有一步时,Agent 直接调用工具就够了。比如“读一下 README”,模型调用一次 read_file,拿到结果,再回复用户。

但任务一复杂,问题就会变得明显:Agent 很容易执行到后面忘记自己原本要做什么,也容易忘记已经做完了哪些步骤。

这并不神秘。人做复杂事情时也一样。如果没有清单,写着写着代码可能就忘了还有测试没跑;修着修着 bug 可能就忘了最开始的复现路径。解决办法也很朴素:先列计划,再按步骤执行,每完成一步就更新状态。

这一篇我们就给 Agent 加上这个能力:TodoWrite

为什么 messages 不够

前几篇里,我们已经一直在维护 messages

  1. 用户提出任务;
  2. 模型返回工具调用;
  3. Harness 执行工具;
  4. 把工具结果作为 tool 消息写回;
  5. 模型继续思考下一步。

理论上,模型可以从完整的 messages 里读出“我做过什么、接下来该做什么”。但在真实任务里,这种方式并不可靠。

原因主要有三个。

第一,messages 是流水账,不是任务状态。里面混着用户需求、模型回复、工具调用参数、工具结果、错误信息和中间解释。模型要从一长串历史里恢复当前进度,成本很高。

第二,工具结果会越来越多。比如读文件、跑测试、搜索代码,都会把大量文本塞进上下文。真正重要的可能只是“已读过配置文件”“测试失败在某个断言”,但这些事实会淹没在原始输出里。

第三,模型的注意力会漂移。上下文越长,越容易出现“前面说过但后面忘了”的情况。尤其是多轮任务里,用户可能中途补充要求,工具可能失败,计划也可能需要调整。

所以我们需要一份更稳定、更紧凑的任务状态。

TodoWrite 的作用就是把“当前要做什么”从流水账里抽出来,变成一份结构化清单。

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

接下来实现工具函数。

它要做三件事:

  1. 校验模型传入的 todos;
  2. 更新当前任务清单;
  3. 返回一段简短结果,写回 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-requirementedit-coderun-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_TOOLREAD_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 本身不需要改。

前几篇已经把工具执行路径抽象成了:

  1. 模型返回 tool_calls
  2. Harness 解析工具名和参数;
  3. run_tool_call_with_hooks() 执行 Hook;
  4. run_tool_call()TOOL_HANDLERS 找函数;
  5. 工具结果写回 messages

TodoWrite 只是多了一个工具,所以可以直接复用这条路径。

Todo 状态管理

TodoWrite 把“任务进度”放在独立状态里;messages 仍然保存对话和工具结果,两者互相补充。

TodoWrite 和权限 Hook 的关系

上一章引入了权限 Hook,那么 TodoWrite 要不要走权限?

我的建议是:走同一条工具执行路径,但默认放行。

因为 TodoWrite 只更新 Harness 内部的任务状态,不读写用户文件,也不执行外部命令,风险比 write_fileedit_filebash 小很多。

可以在权限策略里加一条:

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_dirread_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_filewrite_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,模型更容易在最终总结前检查是否还有 pendingin_progress

第五,给后续能力打基础。 后面要做子代理、上下文压缩、任务持久化,都需要有一个明确的任务状态。TodoWrite 是最小的一步。

什么时候不要用 TodoWrite

TodoWrite 不是所有任务都要用。

如果用户只是问“这个函数是什么意思”,或者让 Agent 读取一个文件、解释一段代码,强行创建待办清单反而会显得啰嗦。

一个简单判断是:

  • 需要读多个文件,适合用;
  • 需要修改代码并验证,适合用;
  • 需要排查错误,适合用;
  • 任务可能超过两三步,适合用;
  • 单次问答、单次读取、单次解释,通常不用。

另外,todo 的粒度也不要太细。

不要把“打开文件”“读取文件第 1 行”“读取文件第 2 行”拆成 todo。Todo 应该描述有意义的阶段,比如“理解现有实现”“修改登录逻辑”“运行测试验证”。

小结

这一篇在前几章的基础上,为 Agent Harness 增加了 TodoWrite:

  1. CURRENT_TODOS 保存当前任务清单;
  2. run_todo_write() 创建和更新完整 todo 快照;
  3. run_todo_write 加入 TOOLS,让模型知道可以维护任务计划;
  4. 把它加入 TOOL_HANDLERS,复用原来的工具分发路径;
  5. 在权限策略里默认放行,但仍然保留 Hook 审计;
  6. 用系统提示词约束模型:复杂任务先列计划,执行中持续更新,完成后再总结。

到这里,Agent 已经不只是“能调用工具”,而是开始具备一种更像工作流的能力:先拆任务,再执行,再根据结果更新计划。

下一篇可以继续往更复杂的协作结构走:Subagent。当一个任务太大,单个 Agent 很难同时兼顾阅读、实现、测试和总结时,就可以把一部分工作交给子代理,让主 Agent 负责规划、分派和整合结果。