TodoWriteTool 与 AskUserQuestionTool:交互工具

📑 目录

在 Claude Code 的 40 余款工具中,有一类工具格外特殊——它们不直接操作文件系统、不执行 Shell 命令、不发起网络请求,而是专注于协调 Agent 与用户的协作关系TodoWriteToolAskUserQuestionTool 正是这类交互工具的代表。前者让 Agent 拥有结构化的"执行骨架",后者让 Agent 能够在关键决策点向用户发起精准提问。本文将深入解析这两款工具的实现原理、交互机制及其在 Agent 循环中的角色。

一、TodoWriteTool:会话级的执行骨架

1.1 文件位置与基本定位

TodoWriteTool 位于 src/tools/TodoWriteTool/TodoWriteTool.tsx,在工具注册表 src/tools.tsgetAllBaseTools() 函数中作为始终可用的基础工具被静态注册。它的 searchHint 被定义为 'manage the session task checklist',这一描述精准地概括了它的核心定位:管理会话任务清单

与很多人直觉认为的"前端装饰"不同,TodoWriteTool 在 Claude Code 的架构中被归类为执行骨架的组成部分。正如玉衡实验室在源码复盘中所指出的:"Todo 并不是 UI 装饰,而是执行主线的组成部分"。这意味着模型在调用 TodoWriteTool 时,不仅仅是"展示"一个任务列表,而是在显式地管理系统运行时的任务状态

// 源码文件:src/tools.ts:193-210(简化)
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    ExitPlanModeV2Tool,
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    NotebookEditTool,
    WebFetchTool,
    TodoWriteTool,        // ← 始终可用的基础工具
    WebSearchTool,
    TaskStopTool,
    AskUserQuestionTool,
    SkillTool,
    EnterPlanModeTool,
    // ...
  ]
}

1.2 待办项的数据结构与状态机

TodoWriteTool 的输入 schema 定义了一个精简但完整的状态机。每个待办项(todo item)包含三个核心字段:id(唯一标识)、content(任务内容)和 status(状态)。状态被严格限制为三个枚举值:

  • pending:任务尚未开始
  • in_progress:当前正在执行的任务,同一时刻只能有一个任务处于此状态
  • completed:任务已完成
// TodoWriteTool 输入 schema(来自工具 prompt 描述)
{
  "type": "object",
  "properties": {
    "todos": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "content": { "type": "string", "minLength": 1 },
          "status": {
            "type": "string",
            "enum": ["pending", "in_progress", "completed"]
          },
          "id": { "type": "string" }
        },
        "required": ["content", "status", "id"],
        "additionalProperties": false
      }
    }
  },
  "required": ["todos"]
}

这种设计使得 TodoWriteTool 的语义非常清晰:模型每次调用该工具时,传入的是整个待办列表的"目标状态",而不是增量更新。工具的 call 方法会将这个新列表与会话状态中存储的旧列表进行比较,并执行原子替换。

1.3 增删改查的实现机制

从源码分析可以还原出 TodoWriteTool 核心 call 方法的工作逻辑:

// 源码文件:src/tools/TodoWriteTool/TodoWriteTool.tsx:48-85(基于多源重构)
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
    }
  }))
  
  return {
    data: {
      oldTodos,
      newTodos
    }
  }
}

这段代码揭示了几个关键设计决策:

第一,作用域隔离todoKey 的取值逻辑是 context.agentId ?? getSessionId(),这意味着如果当前工具调用发生在子 Agent(由 AgentTool 派生)的上下文中,待办列表会绑定到该子 Agent 的 ID;否则绑定到整个会话的 ID。这确保了主 Agent 与子 Agent 各自维护独立的任务清单,避免了任务状态的混乱叠加。

第二,完成即清理。当 allDone 为真时(所有任务都标记为 completed),系统不会保留一个空列表,而是直接将其从状态中移除。这是一种优雅的垃圾回收策略——已完成的任务清单不需要长期占用会话状态空间。

第三,无直接删除 API。从 schema 可以看出,模型对 Todo 列表的"修改"本质上是通过传入完整的替换列表来实现的。如果要删除某个任务,模型只需在调用时传入一个不包含该任务的 todos 数组即可。这种设计简化了状态管理,将"删除"语义统一为"替换"语义的一个特例。

1.4 与 utils/todo/ 的关系

TodoWriteTool 本身只负责工具层面的输入输出协议,而具体的待办数据存储和 UI 渲染逻辑则委托给 src/utils/todo/ 目录下的模块。从项目结构分析,该目录至少包含以下职责:

  • index.ts:提供 Todo 数据的 CRUD 辅助函数和类型定义
  • TodoContext.tsx(或类似组件):管理 Todo 的 React Context,为 UI 层提供状态订阅

这种分层设计遵循了 Claude Code 工具系统的通用模式:工具目录(tools/)负责"协议层"——定义 schema、处理权限、调用执行;工具特定的工具函数(utils/todo/)负责"实现层"——数据存储、状态持久化、UI 渲染。这种分离使得同一套 Todo 数据既可以被模型通过工具接口操作,也可以被用户通过终端 UI 直接查看。

1.5 在会话中的持久化

Todo 列表的持久化是通过 AppState 全局状态树实现的。AppState 是 Claude Code 的核心状态存储,保存在内存中并在会话期间持续存在。从 AppStateStore.ts 的源码线索可以看到,todos 字段是应用状态的顶层属性之一:

// 基于 AppStateStore.ts 结构的重构
type AppState = {
  // ... 其他状态字段
  todos: Record<string, TodoItem[]>  // key 为 sessionId 或 agentId
  // ... 其他状态字段
}

这意味着 Todo 列表的生命周期与会话(Session)或子 Agent 实例绑定。当用户关闭 Claude Code 终端时,这些待办项会随会话结束而丢失——TodoWriteTool 不提供跨会话的持久化能力。这与 TaskCreateTool 创建的长期后台任务形成了鲜明对比,后文将详细讨论这一区别。

二、AskUserQuestionTool:结构化的用户交互

2.1 文件位置与核心架构

AskUserQuestionTool 位于 src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx,同目录下还包含 prompt.js 文件,用于提供系统提示(System Prompt)中关于该工具的详细使用说明。

从源码可以看到,AskUserQuestionTool 是一款标记为只读(isReadOnly: true)、并发安全(isConcurrencySafe: true)但需要用户交互(requiresUserInteraction: true 的特殊工具。这三个属性的组合非常精确地刻画了它的行为特征:

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:95-130(节选)
export const AskUserQuestionTool: Tool = buildTool({
  name: ASK_USER_QUESTION_TOOL_NAME,
  searchHint: 'prompt the user with a multiple-choice question',
  maxResultSizeChars: 100_000,
  shouldDefer: true,
  
  isConcurrencySafe() {
    return true
  },
  isReadOnly() {
    return true
  },
  requiresUserInteraction() {
    return true
  },
  // ...
})

2.2 何时向用户提问

AskUserQuestionTool 的系统提示中明确定义了模型应当主动发起提问的场景。该工具不是让用户自由输入文本,而是呈现结构化的多选问题。以下是工具描述中的关键触发条件:

  • 当面临需要用户做出明确选择的决策点时
  • 当存在多个合理的实现路径,且 Agent 无法自行判断最优方案时
  • 当需要用户确认风险操作(如覆盖文件、执行破坏性命令)的替代方案时
  • /remember 命令需要收集用户偏好设置时

值得注意的是,AskUserQuestionTool 并不替代常规的对话交流。它专门用于那些需要用户从有限选项中做出结构化选择的场景。如果问题可以通过自然语言对话解决,Agent 应该直接回复文本而非调用此工具。

2.3 问题类型和格式

AskUserQuestionTool 的输入 schema 设计体现了对"结构化交互"的深刻思考。一次工具调用可以包含 1 到 4 个问题,每个问题包含以下字段:

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:15-45(节选)
const questionSchema = lazySchema(() => z.object({
  question: z.string().describe(
    'The complete question to ask the user. Should be clear, specific, and end with a question mark.'
  ),
  header: z.string().describe(
    `Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars).`
  ),
  options: z.array(questionOptionSchema()).min(2).max(4).describe(
    'The available choices for this question. Must have 2-4 options.'
  ),
  multiSelect: z.boolean().default(false).describe(
    'Set to true to allow the user to select multiple options instead of just one.'
  )
}))

每个选项(QuestionOption)又包含三层信息:

  • label(1-5 个词):用户在 UI 上看到的选项文本
  • description:选项的详细说明,解释选择该选项的含义或后果
  • preview(可选):当用户聚焦(hover)在该选项上时显示的预览内容,可以是代码片段、mockup 或对比视图
// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:10-20(节选)
const questionOptionSchema = lazySchema(() => z.object({
  label: z.string().describe('The display text for this option... Should be concise (1-5 words)'),
  description: z.string().describe('Explanation of what this option means...'),
  preview: z.string().optional().describe(
    'Optional preview content rendered when this option is focused.'
  )
}))

这种三层信息结构(label / description / preview)实现了渐进式披露的交互模式:用户首先看到简洁的选项标签,需要更多信息时可以查看描述,想要深入理解差异时可以浏览预览内容。

此外,schema 还包含严格的唯一性校验UNIQUENESS_REFINE):同一批问题中的 question 文本必须互不相同,同一问题内的 option label 也必须互不相同。这防止了模型因疏忽而生成重复或歧义的选项。

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:48-65(节选)
const UNIQUENESS_REFINE = {
  check: (data) => {
    const questions = data.questions.map(q => q.question)
    if (questions.length !== new Set(questions).size) {
      return false
    }
    for (const question of data.questions) {
      const labels = question.options.map(opt => opt.label)
      if (labels.length !== new Set(labels).size) {
        return false
      }
    }
    return true
  },
  message: 'Question texts must be unique, option labels must be unique within each question'
}

2.4 用户回答的处理流程

当用户完成选择后,AskUserQuestionTool 的答案处理流程如下:

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:165-195(节选)
async call({ questions, answers = {}, annotations }, _context) {
  return {
    data: {
      questions,
      answers,
      ...(annotations && { annotations })
    }
  }
}

call 方法本身非常轻量——它只将问题和答案打包返回。真正的"处理"发生在结果映射阶段。mapToolResultToToolResultBlockParam 方法将用户的答案转换为标准的 tool_result block,重新注入到 Agent 的对话上下文中:

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:200-230(节选)
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
  const answersText = Object.entries(answers).map(([questionText, answer]) => {
    const annotation = annotations?.[questionText]
    const parts = [`"${questionText}"="${answer}"`]
    if (annotation?.preview) {
      parts.push(`selected preview:\n${annotation.preview}`)
    }
    if (annotation?.notes) {
      parts.push(`user notes: ${annotation.notes}`)
    }
    return parts.join(' ')
  }).join(', ')
  
  return {
    type: 'tool_result',
    content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`,
    tool_use_id: toolUseID
  }
}

这段代码有几个值得注意的细节:

第一,答案以键值对形式记录answers 是一个 Record<string, string>,键是问题文本,值是用户选择的选项 label。如果启用了 multiSelect,多个选择会以逗号分隔的形式存入同一个字符串。

第二,annotations 支持用户附加注释annotations 字段允许用户为每个问题的答案添加 preview(选中的预览内容)和 notes(自由文本备注)。这为用户提供了一种在结构化选择之外表达细微偏好的通道。

第三,结果文本明确提示模型"继续"。返回的消息以 "You can now continue with the user’s answers in mind" 结尾,这是一种对模型的软性指令,引导它基于用户的选择继续后续工作流。

2.5 渠道感知与条件禁用

AskUserQuestionTool 还包含一个重要的环境适配逻辑——在 KAIROS 渠道模式下自动禁用:

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:100-115(节选)
isEnabled() {
  // When --channels is active the user is likely on Telegram/Discord, not
  // watching the TUI. The multiple-choice dialog would hang with nobody at
  // the keyboard.
  if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) {
    return false
  }
  return true
}

这段注释非常坦诚地说明了设计考量:当用户通过 Telegram 或 Discord 等异步渠道与 Agent 交互时,多选对话框会因为"没有人在键盘前"而永远挂起。因此,渠道权限中继层(interactiveHandler.ts)会跳过所有标记为 requiresUserInteraction() 的工具。这是一种务实的可用性保护——宁可降低功能完备性,也要避免陷入无响应状态。

三、中断与恢复:交互工具如何暂停 Agent 循环

理解 TodoWriteTool 和 AskUserQuestionTool 的关键,不仅在于看它们的独立实现,更在于看它们如何嵌入 Claude Code 的核心 Agent 循环中。这两款工具虽然一个"写"一个"问",但共同点是它们都介入了 Agent 与用户的协作流程

3.1 Agent 循环中的交互暂停点

Claude Code 的 Agent 循环(位于 src/query.ts)本质上是一个 while(true) 循环,持续执行"发送消息给模型 → 接收响应 → 如果有 tool_use 则执行工具 → 将结果反馈给模型"的流程。然而,当循环遇到需要用户交互的工具时,这个流程必须显式暂停

sequenceDiagram
    participant User as 用户
    participant REPL as REPL / TUI
    participant Loop as Agent 循环 (query.ts)
    participant Model as Claude API
    participant Tool as 交互工具

    Loop->>Model: 发送上下文 + 可用工具列表
    Model-->>Loop: 返回 tool_use (AskUserQuestionTool)
    Loop->>Tool: 执行 checkPermissions()
    Tool-->>Loop: behavior: 'ask'
    Loop->>REPL: 渲染交互式提问对话框
    REPL->>User: 显示多选问题
    Note over Loop,Tool: Agent 循环在此暂停
    User->>REPL: 选择选项并提交
    REPL->>Tool: 传递用户答案
    Tool->>Loop: 返回 tool_result
    Loop->>Model: 将答案注入上下文,继续循环
    Model-->>Loop: 基于答案继续执行

3.2 等待用户输入的状态管理

当 AskUserQuestionTool 被调用时,权限检查阶段(checkPermissions)会返回 { behavior: 'ask' }

// 源码文件:src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx:155-165(节选)
async checkPermissions(input) {
  return {
    behavior: 'ask' as const,
    message: 'Answer questions?',
    updatedInput: input
  }
}

这个 'ask' 行为会触发 Claude Code 的权限/交互框架,在终端 UI 上渲染一个阻塞式的提问组件。此时,Agent 循环的 JavaScript 执行栈会在 await 点挂起,等待用户通过键盘或鼠标完成交互。

与 AskUserQuestionTool 不同,TodoWriteTool 的权限检查通常返回 { behavior: 'allow' },因为它只修改内存中的状态而不涉及破坏性操作。因此 TodoWriteTool 的执行是非阻塞的——模型调用它后无需等待用户确认,循环可以立即继续。

3.3 恢复执行的流程

用户完成回答后,交互组件会将答案回填到工具的 call 方法中。工具执行完成后,其结果通过 mapToolResultToToolResultBlockParam 转换为标准的 tool_result block,作为一条新的 user message 插入到对话历史。随后 Agent 循环进入下一轮迭代,模型在完整的上下文(包含用户刚刚做出的选择)基础上继续推理和执行。

这种设计确保了交互的原子性:用户的每一次选择都被完整地记录在对话转录(transcript)中,成为模型后续决策的确定性输入,而非被遗忘的 UI 事件。

四、与任务系统的关系:Todo 列表与 Task 的区别

在 Claude Code 中,"任务"这个概念出现在两个不同的抽象层级上,很多开发者容易混淆。厘清它们的区别,是理解交互工具定位的关键。

4.1 概念对比

维度TodoWriteTool(Todo 列表)Task 工具族(TaskCreateTool 等)
生命周期会话级(Session/Agent),内存存储长期后台任务,可跨会话存在
执行实体主 Agent 自身执行独立的子 Agent 或 Shell 进程
状态粒度粗粒度(pending / in_progress / completed)细粒度(运行中、完成、失败、输出流)
持久化AppState 内存,会话结束即消失任务系统持久化,支持查询历史
交互方式模型主动更新状态用户或模型创建,异步执行
典型用途跟踪当前会话的多步骤计划委派独立工作单元(如代码审查、测试运行)

4.2 架构协作关系

Todo 列表和 Task 系统在实际工作流中经常协同出现。一个典型的协作场景是:

  1. 用户请求"帮我重构这个模块的认证逻辑"
  2. 主 Agent 使用 TodoWriteTool 创建计划:[分析现有代码] → [设计新接口] → [实施重构] → [运行测试]
  3. 在执行"运行测试"步骤时,主 Agent 调用 TaskCreateTool 启动一个后台子 Agent 来执行完整的测试套件
  4. 主 Agent 继续推进 Todo 列表的其他步骤,同时通过 TaskGetTool 轮询后台任务的进度
  5. 当所有 Todo 项都标记为 completed 时,TodoWriteTool 自动清空列表
flowchart TD
    A[用户请求: 重构认证模块] --> B[主 Agent 创建 Todo 列表]
    B --> C[1. 分析现有代码]
    C --> D[2. 设计新接口]
    D --> E[3. 实施重构]
    E --> F[4. 运行测试]
    F --> G[TodoWriteTool: 标记 in_progress]
    G --> H[TaskCreateTool: 启动后台测试 Agent]
    H --> I[主 Agent 继续其他工作]
    I --> J[TaskGetTool: 轮询测试结果]
    J --> K{测试通过?}
    K -->|是| L[TodoWriteTool: 标记 completed]
    K -->|否| M[调试修复]
    M --> H
    L --> N[TodoWriteTool: 全部完成,清空列表]

4.3 验证 Agent 与 Todo 的联动

在更高级的工程实践中,TodoWriteTool 还与 VERIFICATION_AGENT 特性存在隐式联动。从源码分析中可以看到,当以下所有条件同时满足时,系统会向模型追加一条"验证提示":

  • VERIFICATION_AGENT 特性开关已启用
  • 当前上下文是主 Agent(!context.agentId
  • 所有 Todo 项刚刚被标记为完成(allDone && todos.length >= 3
  • Todo 列表中没有任何一项的内容包含 "verif" 字样
// 源码文件:src/tools/TodoWriteTool/TodoWriteTool.tsx:70-95(基于多源重构)
if (
  feature('VERIFICATION_AGENT') &&
  getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
  !context.agentId &&
  allDone &&
  todos.length >= 3 &&
  !todos.some(t => /verif/i.test(t.content))
) {
  verificationNudgeNeeded = true
}

const nudge = verificationNudgeNeeded
  ? '\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent...'
  : ''

这段逻辑深刻体现了 Claude Code 的工程哲学:系统不是"做完就汇报",而是"做完之前需要 verification"。Todo 列表在这里扮演了一个质量门禁的角色——它不仅仅是进度跟踪器,更是触发验证流程的状态传感器。

五、总结

TodoWriteTool 和 AskUserQuestionTool 虽然功能迥异,但共同体现了 Claude Code 在"人机协作"层面的深度思考。

TodoWriteTool 将会话级的任务规划做成了显式的系统状态。它让模型不再只是"心里默默记着"下一步要做什么,而是通过标准化的工具调用来声明和更新执行计划。这种显式化带来了多重收益:用户可以通过终端 UI 实时看到 Agent 的工作进展;模型自身在每次决策时都能从上下文中读取到最新的任务状态;系统还能基于 Todo 的完成模式触发验证等后续流程。

AskUserQuestionTool 则将"何时向用户求助"这一决策从模型的"自由意志"提升到了受控的协议层面。通过结构化的多选问题和渐进式披露的选项设计,它既避免了模型在不确定时盲目猜测,又避免了用户被开放式提问淹没。requiresUserInteraction() 标记和渠道感知禁用逻辑,更确保了交互工具在各种部署形态下的可靠性。

在 Claude Code 的 Harness Engineering 体系中,这两款交互工具的价值可以用一句话概括:它们让 Agent 与用户的协作从"暗箱对话"变成了"结构化协作"——每一个任务进度都可见,每一个关键决策都有用户的明确输入作为锚点。这种设计不仅提升了用户体验的可预测性,更为多 Agent 协作、任务审计和自动化评估奠定了数据基础。