第 2 章 · Agent Loop 与任务规划

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,再流向 completedfailed。不提供"回退"机制——一个步骤标记为 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_todosupdate_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 章的 searchToolreadFileTool 有一个关键区别:它们不是直接导出的常量,而是工厂函数返回的对象。

为什么?

/** 第 1 章的工具:无状态,直接导出常量 */
export const searchTool: Tool = { /* ... */ };

/** 第 2 章的工具:需要访问 TodoManager,用工厂函数创建 */
export function createTodosTool(todoManager: TodoManager): Tool { /* ... */ }

searchTool 不需要记住任何东西——每次执行都是独立的。但 create_todos 创建的步骤必须存下来,update_todo 才能更新它们。所以这两个工具必须共享同一个 TodoManager 实例。

工厂函数 + 闭包是最简单的共享方式:createTodosTool(todoManager) 返回的工具对象内部引用了传入的 todoManagerupdateTodoTool(todoManager) 也引用了同一个实例。两个工具通过闭包共享状态,调用方只需要传入同一个 todoManager

注册到工具列表

agent.ts 中,把计划工具和基础工具合并:

const allTools = [
  ...tools,
  createTodosTool(todoManager),
  updateTodoTool(todoManager),
];

这样模型调用时就有了四个工具可用:searchread_filecreate_todosupdate_todo

这一节做了什么

  • 定义了 TodoItem 类型和 TodoStatus 状态
  • 实现了 TodoManager 类来管理步骤的创建、更新和查询
  • 用工厂函数模式创建了 create_todosupdate_todo 两个工具
  • 说明了闭包捕获如何让工具共享 TodoManager 实例

下一节解决循环的终止问题:什么时候该停?失败了怎么恢复?

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page