Claude Code / s03
TodoWrite:让 Agent 不再迷路
s01/s02 的 Agent 能执行工具,但在多步任务里容易迷路——重复做已完成的事,跳过某个步骤,或者中途忘记目标。
这一节加一个规划层:TodoManager。
重要更新:Claude Code 内部已经将 TodoWrite 标记为 V1(已弃用),新版本使用基于文件持久化的 Task 工具族(V2)。本文会先讲 V1 的设计思路,再深入 V2 的真实源码实现。
问题
没有规划层时,模型靠对话历史”记住”任务进度。这有几个问题:
- 重复工作:读了文件 A 又读一遍
- 跳过步骤:忘记某个子任务
- 上下文漂移:多轮工具调用后,最初的任务目标被淹没
人在做复杂任务时会列清单。Agent 也需要。
V1 设计:TodoWrite + TodoManager
Python 实现
from dataclasses import dataclass, field
from typing import List, Literal
import json
Status = Literal["pending", "in_progress", "completed"]
@dataclass
class TodoItem:
id: str
content: str
status: Status = "pending"
class TodoManager:
def __init__(self):
self.todos: List[TodoItem] = []
self._rounds_since_update = 0
def add(self, content: str) -> str:
item = TodoItem(id=str(len(self.todos) + 1), content=content)
self.todos.append(item)
return item.id
def update(self, todo_id: str, status: Status):
for item in self.todos:
if item.id == todo_id:
# 同时只能有一个 in_progress
if status == "in_progress":
for other in self.todos:
if other.status == "in_progress":
other.status = "pending"
item.status = status
self._rounds_since_update = 0
return f"Updated {todo_id} → {status}"
return f"Todo {todo_id} not found"
def view(self) -> str:
if not self.todos:
return "No todos."
lines = []
for item in self.todos:
icon = {"pending": "○", "in_progress": "◐", "completed": "●"}[item.status]
lines.append(f"{icon} [{item.id}] {item.content}")
return "\n".join(lines)
def attention_check(self) -> str | None:
"""3 轮没有更新 todo → 注入提醒"""
self._rounds_since_update += 1
if self._rounds_since_update >= 3:
pending = [t for t in self.todos if t.status != "completed"]
if pending:
return f"\n[提醒] 还有 {len(pending)} 个未完成的任务,记得更新 todo 状态。"
return None
关键设计:同时只能有一个 in_progress 任务。 这强迫模型专注于当前任务,而不是同时标记多个任务为进行中。
V1 的局限
V1 是纯内存方案——所有 todo 存在 appState.todos 里:
// TodoWriteTool.ts — V1 的核心逻辑
async call({ todos }, context) {
const appState = context.getAppState()
const todoKey = context.agentId ?? getSessionId()
const oldTodos = appState.todos[todoKey] ?? []
const allDone = todos.every(_ => _.status === 'completed')
const newTodos = allDone ? [] : todos
context.setAppState(prev => ({
...prev,
todos: {
...prev.todos,
[todoKey]: newTodos,
},
}))
// ...
}
每次调用时,模型要传入整个 todo 列表的完整快照来覆盖旧列表。这意味着:
- 无法跨会话持久化——进程退出,任务列表消失
- 无法多 Agent 共享——内存状态无法被其他进程读取
- 操作粒度粗——不能单独更新一个任务,必须传整个列表
todo 工具
todo_manager = TodoManager()
def run_todo(action: str, content: str = None,
todo_id: str = None, status: str = None) -> str:
if action == "add":
return f"Added: {todo_manager.add(content)}"
elif action == "update":
return todo_manager.update(todo_id, status)
elif action == "view":
return todo_manager.view()
return f"Unknown action: {action}"
TOOL_HANDLERS["todo"] = lambda **kw: run_todo(**kw)
加进 TOOLS schema:
{
"name": "todo",
"description": "管理任务列表。在开始多步任务时先创建 todo,每完成一步更新状态。",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "update", "view"],
"description": "add: 添加任务, update: 更新状态, view: 查看列表"
},
"content": {"type": "string", "description": "任务内容(add 时必填)"},
"todo_id": {"type": "string", "description": "任务 ID(update 时必填)"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
"description": "新状态(update 时必填)"
}
},
"required": ["action"]
}
}
attention_check:注意力机制
模型有时会忘记更新 todo。在每次 LLM 调用前检查:
def agent_loop(query: str):
messages = [{"role": "user", "content": query}]
while True:
# 检查是否需要注入提醒
reminder = todo_manager.attention_check()
if reminder:
messages[-1]["content"] += reminder # 附加到最新消息
response = client.messages.create(...)
# ... 正常循环 ...
3 轮没有更新 todo 时,提醒会附加到当前消息末尾,让模型重新关注任务进度。
V2:Task 工具族
Claude Code 在内部用 isTodoV2Enabled() 做功能开关,将 TodoWrite(V1)替换为一组细粒度的 Task 工具。V2 的核心差异:文件持久化 + 单任务操作 + hooks 生命周期。
切换机制:isTodoV2Enabled
// src/utils/tasks.ts
export function isTodoV2Enabled(): boolean {
// 通过环境变量强制启用(供 SDK 用户使用)
if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TASKS)) {
return true
}
// 交互式会话默认启用 V2;非交互式(SDK)默认用 V1
return !getIsNonInteractiveSession()
}
这个函数同时控制两侧:
// TodoWriteTool.ts — V1 只在 V2 未启用时生效
isEnabled() {
return !isTodoV2Enabled()
}
// TaskCreateTool.ts — V2 只在启用时生效
isEnabled() {
return isTodoV2Enabled()
}
在 src/tools.ts 中,工具列表根据开关动态组装:
// src/tools.ts
TodoWriteTool, // V1,始终注册但 isEnabled 受控
...(isTodoV2Enabled()
? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
: []),
Task 数据模型
V2 的任务不再是内存对象,而是磁盘上的 JSON 文件:
// src/utils/tasks.ts — Task schema
export const TaskSchema = lazySchema(() =>
z.object({
id: z.string(),
subject: z.string(), // 任务标题(祈使句)
description: z.string(), // 任务描述
activeForm: z.string().optional(), // 进行中的展示文案(现在分词形式)
owner: z.string().optional(), // 负责的 Agent ID
status: TaskStatusSchema(), // pending | in_progress | completed
blocks: z.array(z.string()), // 本任务阻塞了哪些任务
blockedBy: z.array(z.string()), // 哪些任务阻塞了本任务
metadata: z.record(z.string(), z.unknown()).optional(),
}),
)
存储路径:~/.claude/tasks/{taskListId}/{taskId}.json。每个任务一个文件,支持文件锁(proper-lockfile)防止并发冲突。
五个 Task 工具
TaskCreate:创建任务
// src/tools/TaskCreateTool/TaskCreateTool.ts
inputSchema: z.strictObject({
subject: z.string(), // 任务标题
description: z.string(), // 任务描述
activeForm: z.string().optional(), // spinner 展示文案
metadata: z.record(z.string(), z.unknown()).optional(),
})
创建时自动触发 executeTaskCreatedHooks——如果 hook 返回 blockingError,任务会被立即删除:
async call({ subject, description, activeForm, metadata }, context) {
const taskId = await createTask(getTaskListId(), {
subject, description, activeForm,
status: 'pending',
owner: undefined,
blocks: [], blockedBy: [],
metadata,
})
// 执行 TaskCreated hooks
const generator = executeTaskCreatedHooks(taskId, subject, description, ...)
for await (const result of generator) {
if (result.blockingError) {
blockingErrors.push(getTaskCreatedHookMessage(result.blockingError))
}
}
if (blockingErrors.length > 0) {
await deleteTask(getTaskListId(), taskId)
throw new Error(blockingErrors.join('\n'))
}
// ...
}
TaskUpdate:更新任务
可更新的字段最多:
inputSchema: z.strictObject({
taskId: z.string(),
subject: z.string().optional(),
description: z.string().optional(),
activeForm: z.string().optional(),
status: TaskUpdateStatusSchema.optional(), // 包含 'deleted' 特殊状态
addBlocks: z.array(z.string()).optional(), // 添加阻塞关系
addBlockedBy: z.array(z.string()).optional(),
owner: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
设置 status: 'deleted' 会物理删除任务文件。标记为 completed 时触发 executeTaskCompletedHooks。
一个重要细节:当 Agent Swarm 模式开启时,标记 in_progress 会自动设置 owner:
// 自动设置 owner
if (isAgentSwarmsEnabled() && status === 'in_progress'
&& owner === undefined && !existingTask.owner) {
const agentName = getAgentName()
if (agentName) {
updates.owner = agentName
}
}
TaskGet:查询单个任务
inputSchema: z.strictObject({
taskId: z.string(),
})
// 返回: { id, subject, description, status, blocks, blockedBy }
只读操作,标记了 isReadOnly: true 和 isConcurrencySafe: true。
TaskList:列出所有任务
inputSchema: z.strictObject({}) // 无参数
会过滤掉 metadata._internal 为 true 的内部任务,并且从 blockedBy 中去除已完成任务的引用——让模型看到的阻塞关系始终是有效的。
TaskStop:停止后台任务
inputSchema: z.strictObject({
task_id: z.string().optional(),
shell_id: z.string().optional(), // 向后兼容已废弃的 KillShell
})
这个工具停止的是后台运行的进程任务(如长时间运行的 shell 命令),不是 Task 列表中的逻辑任务。它保留了 KillShell 作为别名以兼容旧的 transcript。
V2 的文件存储与并发控制
// src/utils/tasks.ts
// 任务存储目录
export function getTasksDir(taskListId: string): string {
return join(getClaudeConfigHomeDir(), 'tasks', sanitizePathComponent(taskListId))
}
// 创建任务 — 带文件锁
export async function createTask(taskListId, taskData): Promise<string> {
const lockPath = await ensureTaskListLockFile(taskListId)
let release = await lockfile.lock(lockPath, LOCK_OPTIONS)
try {
const highestId = await findHighestTaskId(taskListId)
const id = String(highestId + 1)
const task = { id, ...taskData }
await writeFile(getTaskPath(taskListId, id), JSON.stringify(task, null, 2))
return id
} finally {
await release()
}
}
锁的参数为 swarm 场景优化——最多支持 10+ 个并发 Agent,重试 30 次,退避 5-100ms:
const LOCK_OPTIONS = {
retries: { retries: 30, minTimeout: 5, maxTimeout: 100 }
}
删除任务时还会维护 high water mark 文件(.highwatermark),确保已删除的任务 ID 不会被复用。
Verification Nudge:防止跳过验证
V1 和 V2 都有一个相同的安全机制——当 3 个以上任务全部标记为 completed,且没有任何一个包含 “verif” 关键词时,工具返回中会注入一段提醒:
// V2 中的实现(TaskUpdateTool.ts)
if (updates.status === 'completed') {
const allTasks = await listTasks(taskListId)
const allDone = allTasks.every(t => t.status === 'completed')
if (allDone && allTasks.length >= 3
&& !allTasks.some(t => /verif/i.test(t.subject))) {
verificationNudgeNeeded = true
}
}
注入的提醒会要求模型在写总结之前先 spawn 一个 verification agent。这是对”所有任务完成 -> 循环退出 -> 跳过验证”这个常见失败模式的结构性防御。
V1 vs V2 对比
| 维度 | V1 (TodoWrite) | V2 (Task 工具族) |
|---|---|---|
| 存储 | 内存 (appState.todos) |
文件 (~/.claude/tasks/) |
| 操作粒度 | 整个列表覆盖 | 单任务 CRUD |
| 工具数量 | 1 个 | 4 个 (Create/Update/Get/List) |
| 并发安全 | 无 | 文件锁 (proper-lockfile) |
| 多 Agent 共享 | 不支持 | 支持 (通过 taskListId) |
| Hooks 集成 | 无 | TaskCreated / TaskCompleted |
| 依赖关系 | 无 | blocks / blockedBy |
| 任务所有权 | 无 | owner 字段 |
| 持久化 | 进程内 | 跨会话 |
源码实证
以下是文中引用的关键源码文件,均来自 Claude Code 仓库:
| 文件路径 | 说明 |
|---|---|
src/tools/TodoWriteTool/TodoWriteTool.ts |
V1 工具实现,isEnabled 返回 !isTodoV2Enabled() |
src/tools/TodoWriteTool/prompt.ts |
V1 的完整 prompt,定义使用场景和任务状态规则 |
src/tools/TaskCreateTool/TaskCreateTool.ts |
V2 创建任务,触发 executeTaskCreatedHooks |
src/tools/TaskUpdateTool/TaskUpdateTool.ts |
V2 更新任务,支持 deleted 状态和 hooks |
src/tools/TaskGetTool/TaskGetTool.ts |
V2 查询任务,只读 + 并发安全 |
src/tools/TaskListTool/TaskListTool.ts |
V2 列出任务,过滤内部任务和已完成的阻塞引用 |
src/tools/TaskStopTool/TaskStopTool.ts |
停止后台运行任务(兼容旧 KillShell) |
src/utils/tasks.ts |
核心:isTodoV2Enabled()、文件存储、锁、Task schema |
src/tools.ts |
工具注册入口,根据 isTodoV2Enabled() 动态切换 |
典型执行过程
对于”重构 src/ 目录下的所有模块”这样的任务:
模型第 1 轮:
todo(add, "分析现有代码结构")
todo(add, "重构 auth.py")
todo(add, "重构 api.py")
todo(add, "运行测试")
todo(update, "1", "in_progress")
模型第 2 轮:
bash("find src/ -name '*.py'")
read_file("src/auth.py")
todo(update, "1", "completed")
todo(update, "2", "in_progress")
模型第 3 轮:
edit_file("src/auth.py", ...)
todo(update, "2", "completed")
todo(update, "3", "in_progress")
...
设计哲学:渐进式复杂度
TodoWrite 的 V1→V2 演化是 Claude Code 设计指南提到的渐进式复杂度原则的典型案例:
入门用户:不需要知道任务系统的存在,Claude 自动管理工作流程。
进阶用户:V1 的 TodoWrite 提供了简单的任务跟踪——一个字典,几个操作,足够应付大多数场景。
专家用户:V2 的 Task 工具族提供了完整的后台执行、磁盘持久化、并发控制,支持复杂的多任务工作流。
每个层次都是完整可用的,不需要理解下一层才能使用当前层。这和 Claude Code 整体的设计理念一致——对新手友好,对专家强大。
设计指南还强调了代码即文档原则。V2 源码中 isTodoV2Enabled() 的命名就是一个例子:函数名本身就解释了它的用途,注释只补充”为什么”(feature flag + 客户端版本检查),不重复”做什么”。
相对 s02 的变化
| 组件 | s02 | s03 |
|---|---|---|
| 工具数量 | 4 | 5(新增 todo) |
| 任务规划 | 无 | TodoManager |
| 注意力机制 | 无 | 3 轮无更新自动提醒 |
| Agent loop | — | 不变 |