03 · Todo 机制
实现 TodoItem 类型、TodoManager 管理器和两个计划工具(create_todos / update_todo),让模型能拆分任务、跟踪进度。
为什么需要 Todo
没有 Todo 机制的 agent 面对复杂任务时,完全靠模型"自己记住做到哪了"。问题是模型的"记忆"就是对话历史——对话越长,越容易丢上下文。模型可能在第 8 轮忘了第 2 轮决定的事情,重复执行已经做过的步骤,或者跳过应该做的步骤。
Todo 机制解决的就是这个问题:把任务拆成显式的步骤,每一步有清晰的状态。 模型不需要"记住",它只需要"看"当前的 Todo 列表。
这和真实开发中的任务管理是一个道理——大任务写在 issue 里拆成 checklist,做完一个勾一个,比全靠脑子记可靠得多。
定义步骤类型
一个步骤需要三样东西:唯一标识、描述文字、当前状态。
// src/types.ts
/** 步骤状态 */
type TodoStatus = "pending" | "running" | "completed" | "failed";
/** 执行计划中的单个步骤 */
interface TodoItem {
id: string;
description: string;
status: TodoStatus;
}四种状态对应步骤的生命周期:
| 状态 | 含义 | 什么时候变成这个状态 |
|---|---|---|
pending | 等待执行 | 创建时 |
running | 正在执行 | 模型开始做这一步时 |
completed | 已完成 | 模型做完了这一步 |
failed | 执行失败 | 模型尝试了但失败了 |
状态只从 pending 流向 running,再流向 completed 或 failed。不提供"回退"机制——一个步骤标记为 completed 就不会变回 pending。这让状态流转简单可预测。
TodoManager:步骤管理器
需要一个类来管理步骤的创建和状态更新。每次 runAgent 调用创建一个新实例,任务之间互不干扰。
// src/todo.ts
import type { TodoItem, TodoStatus } from "./types";
export class TodoManager {
private items: TodoItem[] = [];
private nextId = 1;
/** 批量创建步骤,全部初始化为 pending */
createItems(descriptions: string[]): TodoItem[] {
const newItems = descriptions.map((desc) => ({
id: String(this.nextId++),
description: desc,
status: "pending" as const,
}));
this.items.push(...newItems);
return [...newItems];
}
/** 更新指定步骤的状态 */
updateStatus(id: string, status: TodoStatus): TodoItem | undefined {
const item = this.items.find((t) => t.id === id);
if (!item) return undefined;
item.status = status;
return { ...item };
}
getAll(): readonly TodoItem[] {
return [...this.items];
}
/** 判断是否所有步骤都已完成 */
allCompleted(): boolean {
return (
this.items.length > 0 &&
this.items.every((t) => t.status === "completed")
);
}
/** 格式化为可读文本,作为工具返回值或系统提示的一部分 */
formatForPrompt(): string {
if (this.items.length === 0) return "当前没有执行计划。";
const labels: Record<TodoStatus, string> = {
pending: "[ ]",
running: "[>]",
completed: "[x]",
failed: "[!]",
};
const lines = this.items.map(
(t) => ` ${labels[t.status]} #${t.id} ${t.description}`,
);
return `执行计划:\n${lines.join("\n")}`;
}
}几个设计决策:
为什么用自增 ID 而不是 UUID? 因为 ID 需要短且好记——模型在调用 update_todo 时要传 ID,"1" 比 "550e8400-e29b-41d4-a716-446655440000" 容易写对。自增 ID 也方便用户在终端中阅读。
为什么 formatForPrompt 返回文本而不是 JSON? 因为这段文本会作为工具的返回值,被追加到对话历史中发给模型。文本比 JSON 更省 token,模型也更容易理解。
为什么 allCompleted 要求至少有一个步骤? 如果没有任何步骤,说明模型没有创建计划。这种情况不算"全部完成"——可能只是一个简单问题,模型直接回答了。
两个计划工具
现在把 TodoManager 暴露给模型。方法是创建两个工具:create_todos 和 update_todo。
create_todos
// src/tools/create-todos.ts
import type { Tool } from "../types";
import type { TodoManager } from "../todo";
export function createTodosTool(todoManager: TodoManager): Tool {
return {
name: "create_todos",
description:
"创建执行计划,把任务拆分为多个步骤。" +
"步骤会按顺序编号,初始状态为 pending。",
parameters: {
type: "object",
properties: {
items: {
type: "array",
items: { type: "string" },
description: "按执行顺序排列的步骤描述列表",
},
},
required: ["items"],
},
async execute(args: Record<string, unknown>): Promise<string> {
const descriptions = args.items as string[];
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return "错误:必须提供至少一个步骤描述。";
}
const items = todoManager.createItems(descriptions);
return `已创建 ${items.length} 个步骤:\n${todoManager.formatForPrompt()}`;
},
};
}update_todo
// src/tools/update-todo.ts
import type { Tool, TodoStatus } from "../types";
import type { TodoManager } from "../todo";
const VALID_STATUSES: TodoStatus[] = ["running", "completed", "failed"];
export function updateTodoTool(todoManager: TodoManager): Tool {
return {
name: "update_todo",
description:
"更新某个步骤的执行状态。" +
"开始执行时标记为 running,完成后标记为 completed,失败时标记为 failed。",
parameters: {
type: "object",
properties: {
id: {
type: "string",
description: "步骤 ID(创建时分配的编号)",
},
status: {
type: "string",
description: "新状态:running、completed 或 failed",
},
},
required: ["id", "status"],
},
async execute(args: Record<string, unknown>): Promise<string> {
const id = String(args.id);
const status = String(args.status) as TodoStatus;
if (!VALID_STATUSES.includes(status)) {
return `错误:无效状态 "${status}",可选值: ${VALID_STATUSES.join(", ")}`;
}
const item = todoManager.updateStatus(id, status);
if (!item) {
return `错误:未找到 ID 为 "${id}" 的步骤。`;
}
return `步骤 #${item.id} "${item.description}" → ${status}\n${todoManager.formatForPrompt()}`;
},
};
}注意:VALID_STATUSES 不包含 pending。步骤创建时自动就是 pending,模型不应该把步骤从 completed 改回 pending。
工厂函数:闭包捕获 TodoManager
这两个工具和第 1 章的 searchTool、readFileTool 有一个关键区别:它们不是直接导出的常量,而是工厂函数返回的对象。
为什么?
/** 第 1 章的工具:无状态,直接导出常量 */
export const searchTool: Tool = { /* ... */ };
/** 第 2 章的工具:需要访问 TodoManager,用工厂函数创建 */
export function createTodosTool(todoManager: TodoManager): Tool { /* ... */ }searchTool 不需要记住任何东西——每次执行都是独立的。但 create_todos 创建的步骤必须存下来,update_todo 才能更新它们。所以这两个工具必须共享同一个 TodoManager 实例。
工厂函数 + 闭包是最简单的共享方式:createTodosTool(todoManager) 返回的工具对象内部引用了传入的 todoManager,updateTodoTool(todoManager) 也引用了同一个实例。两个工具通过闭包共享状态,调用方只需要传入同一个 todoManager。
注册到工具列表
在 agent.ts 中,把计划工具和基础工具合并:
const allTools = [
...tools,
createTodosTool(todoManager),
updateTodoTool(todoManager),
];这样模型调用时就有了四个工具可用:search、read_file、create_todos、update_todo。
这一节做了什么
- 定义了
TodoItem类型和TodoStatus状态 - 实现了
TodoManager类来管理步骤的创建、更新和查询 - 用工厂函数模式创建了
create_todos和update_todo两个工具 - 说明了闭包捕获如何让工具共享 TodoManager 实例
下一节解决循环的终止问题:什么时候该停?失败了怎么恢复?
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。