Gemini cli 源码分析之工具篇:Write-Todos 工具的任务拆解与状态流转

发布时间:2026/10/25 1:53:07
Gemini cli 源码分析之工具篇:Write-Todos 工具的任务拆解与状态流转

1. Write-Todos 工具到底在解决什么问题:从一次多步任务跑偏说起

如果你用 Gemini cli 跑过稍微复杂一点的任务,比如「把 src 下所有 fetch 调用改成统一封装的 request 方法,并补上错误处理」,大概率遇到过这种情况:模型前两步改得挺好,第三步突然开始重复改同一个文件,或者干脆忘了自己已经改过哪些文件。这不是模型能力问题,而是 CLI 工具层缺少一个显式的任务状态载体。Write-Todos 就是 Gemini cli 里专门用来解决这个问题的工具,它让模型把任务拆成清单、标记进度、并在终端 UI 上实时展示,从而把「多步执行」从模型脑内记忆变成可观测的外部状态。

这个工具适合谁读源码?一类是想给自家 Agent 框架加任务清单能力的开发者,一类是想搞清楚「声明式工具 + 状态流转 + UI 联动」这套组合拳怎么落地的人。我试过把它的实现拆开看,发现真正有价值的不是那个 Todo 数组本身,而是三件事:参数校验里强制「同时只能有一个 in_progress」的业务规则、工具执行结果如何进入 history 并被 UI 倒序检索、以及测试文件如何用参数化测试覆盖两种显示模式。这三块拼起来,才是一个能用的任务管理工具,而不是一个花架子。

本篇的阅读路径是这样安排的:先定位源码文件与关键函数,再讲清楚数据结构与状态机,然后给出可复制的配置片段让你在本地触发 Write-Todos,接着用实际请求验证行为,最后把常见报错对照源码逐条排查。全程围绕packages/core/src/tools/write-todos.ts和packages/cli/src/ui/components/messages/Todo.tsx两个文件展开,你可以边读边在编辑器里跳转对照。

需要先说明一个前提:Write-Todos 在 Gemini cli 里默认是关闭的,由配置项useWriteTodos控制,必须显式开启才会进入模型的工具列表。很多人读源码时纳闷「为什么我跑起来模型从不调用它」,答案就在这个开关上。后面第 3 节会给出完整的开启方式。

另外提醒一点,读这类工具层源码,别一上来就通读全文。正确姿势是先看类型定义(TodoStatus、Todo、TodoList),再看工具类的validateToolParamValues,最后看execute的返回结构。因为声明式工具的核心契约就藏在这三处:输入长什么样、什么输入算非法、输出长什么样。把这三者吃透,剩下的 UI 部分只是消费者。

2. 源码阅读路径与关键函数定位:Write-Todos 工具层结构拆解

先给一张定位清单,你可以直接按文件路径跳转。核心实现集中在packages/core/src/tools/write-todos.ts,约 229 行;类型定义在packages/core/src/tools/tools.ts的 605 到 610 行附近;UI 组件在packages/cli/src/ui/components/messages/Todo.tsx;测试在write-todos.test.ts(约 109 行)和Todo.test.tsx。这四个文件构成一个完整的闭环:定义、实现、展示、验证。

数据结构非常克制,就三个类型。TodoStatus是四个字面量联合类型:pending、in_progress、completed、cancelled。Todo只有两个字段:description字符串和status。TodoList包一层todos数组。这种极简设计的好处是 JSON Schema 好写、模型好生成、UI 好渲染,代价是无法表达优先级、截止时间、子任务嵌套。如果你要扩展,建议在Todo上加可选字段而不是改状态枚举,否则状态机逻辑会失控。

状态流转的约束是这套设计的灵魂。四种状态里,pending是初始态,in_progress是执行态,completed和cancelled是终态。关键业务规则写在validateToolParamValues里:统计status === 'in_progress'的数量,大于 1 就直接抛错,错误信息是Invalid parameters: Only one task can be "in_progress" at a time.。这条规则的意义在于强制模型线性推进任务,避免它同时声称多个任务在进行中却一个都没做完。从状态机角度看,合法迁移是pending → in_progress → completed/cancelled,且任意时刻in_progress集合大小不超过 1。

工具类本身继承自BaseDeclarativeTool,这是理解调用链路的关键。声明式工具意味着参数校验不靠手写 if-else 堆砌,而是用 JSON Schema 描述参数形状,框架层负责基础类型校验,业务规则再在validateToolParamValues里补充。WriteTodosToolInvocation类(约 98 到 140 行)负责实际执行,它的execute方法返回两部分:人类可读的格式化文本,以及结构化的resultDisplay(就是TodoList)。这个双返回结构很重要,前者给模型看,后者给 UI 看,职责分离得很干净。

调用链路可以这样串起来:用户请求进入 → 模型判断任务复杂度 → 决定调用write_todos_list→ 框架用 JSON Schema 校验参数 →validateToolParamValues检查单一 in_progress 规则 →execute生成结果 → 结果写入uiState.history的tool_group项 →TodoTray组件用useMemo倒序遍历 history 找到最新的resultDisplay→ 渲染状态图标。这条链路上任何一环出问题,表现都是「清单不显示」或「状态不对」,第 5 节会按环节给排查方法。

UI 侧的状态图标系统值得单独说。TodoStatusDisplay组件用 switch 映射四种状态:completed是绿色对勾,in_progress是蓝色箭头»,pending是灰色方框,cancelled是红色叉号。颜色取自主题的status.success、text.accent、text.secondary、status.error。这种映射看似琐碎,但它决定了用户能否一眼扫出进度。TodoTray还有两个显示模式:showFullTodos为 true 时展开全部条目,为 false 时只显示标题和当前进行中的任务,用 Ctrl+T 切换。无活跃任务时面板自动隐藏,这个逻辑避免了空清单占屏。

测试文件是很好的使用说明。write-todos.test.ts里有一条测试专门验证「两个 in_progress 必须抛错」,参数构造和断言错误信息都写得很直白。Todo.test.tsx用describe.each([true, false])参数化跑两种显示模式,还用toMatchSnapshot()做视觉回归。读测试比读实现快,因为测试直接告诉你「什么输入对应什么输出」,这是源码分析里性价比最高的部分。

3. 可复制配置:在本地开启 Write-Todos 并接入模型服务

要让 Write-Todos 真正跑起来,需要两步:开启配置开关,以及给 Gemini cli 配一个可用的模型服务端点。第二步涉及 Base URL、API Key、Model ID 三件套,这里用 TaoToken 的兼容端点做示例,它的 API 地址是https://taotoken.net/api,模型对话入口在 deep link 里可以找到。注意配置里只写 API 根地址,不要带多余路径。

先看配置开关。Gemini cli 的配置文件通常在用户目录下的 settings 文件里,useWriteTodos默认 false,改成 true 即可。下面是一个可复制的 JSON 片段,路径按你本地实际位置调整:

{ "useWriteTodos": true, "model": { "name": "gemini-2.5-pro", "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的密钥" } }

如果你用的是环境变量方式注入密钥,把apiKey那行换成引用即可,比如"apiKey": "${TAOTOKEN_API_KEY}",然后在 shell 里 export。这样做的好处是配置文件可以进版本库而不泄露密钥。Model ID 要写服务端实际支持的名称,写错了会在请求阶段报模型不存在,而不是在配置加载阶段报错,这点要留意。

开启之后,怎么确认工具真的进了模型可见列表?最直接的办法是启动 CLI 后问它「你现在有哪些工具可用」,或者直接给一个多步任务看它是否主动调用write_todos_list。如果模型不调用,先检查useWriteTodos是否生效,再检查模型是否支持工具调用(function calling)。有些轻量模型不支持工具调用,表现就是永远只回文本。

关于 API Key 的获取,走 TaoToken 的 API Keys 页面生成即可,deep link 是https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。生成后立刻复制保存,页面通常只展示一次。如果你打算长期跑编码类任务,Coding Plan 会比按量计费更划算,入口在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。

配置写完后,建议先用一个最小请求验证端点连通性,再启动 CLI。最小请求可以用 curl 打模型对话接口,确认返回 200 且有内容,再去跑 CLI。这样能把「网络/密钥问题」和「CLI 配置问题」分开,排障时少走弯路。验证请求的具体写法放在下一节。

还有一个容易忽略的点:Write-Todos 的启用是全局的,一旦开启,模型在所有会话里都可能调用它。如果你只想在特定项目里用,可以检查 CLI 是否支持项目级配置覆盖。源码里配置读取走的是统一配置系统,项目级 settings 优先级高于用户级,具体文件名以你本地版本为准,改完重启 CLI 生效。

4. 验证请求与成功结果:触发 Write-Todos 并观察状态流转

验证分两层:先验证模型服务端点通,再验证工具被调用且状态正确流转。第一层用 curl 打一个带工具定义的请求,第二层在 CLI 里给多步任务观察输出。两层都过了,说明配置和源码行为一致。

先看端点验证。下面这个请求体是最小化的模型对话调用,注意tools字段里放了write_todos_list的 JSON Schema 简化版,实际 Schema 以源码为准。这个请求的目的是确认服务端接受工具定义并返回tool_calls:

curl -s https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "gemini-2.5-pro", "messages": [ {"role": "user", "content": "把重构任务拆成三步并写入待办清单"} ], "tools": [{ "type": "function", "function": { "name": "write_todos_list", "description": "写入或更新待办事项清单", "parameters": { "type": "object", "properties": { "todos": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"]} }, "required": ["description", "status"] } } }, "required": ["todos"] } } }] }'

成功返回里应该能看到choices[0].message.tool_calls,其中function.name是write_todos_list,arguments是一个 JSON 字符串,解析后包含todos数组。如果返回的是纯文本而没有tool_calls,说明模型没选择调用工具,可以换更强的模型或把用户指令写得更明确。如果返回 401,是密钥问题;如果返回 404,多半是 baseUrl 多写了路径。

第二层验证在 CLI 里做。启动 Gemini cli,输入一个明确的多步任务,比如「读取 package.json,列出所有 dependencies,然后逐个检查是否有更新版本,最后汇总成表格」。观察终端是否出现待办面板,以及面板里的条目状态是否从 pending 变成 in_progress 再变成 completed。按 Ctrl+T 切换完整/简化视图,确认两种模式都正常。任务结束后面板应自动隐藏。

判断「成功」的标准有三条:模型确实调用了write_todos_list(可以在 CLI 的调试输出或 history 里看到tool_group项);清单里任意时刻最多一个in_progress;最终所有条目要么 completed 要么 cancelled,没有卡在 pending 的。第三条经常被忽略,如果任务结束时还有 pending 条目,说明模型没走完流程,这时候可以追问「还有未完成的任务吗」让它继续。

如果你想更细地观察状态流转,可以在 CLI 里让它「先只写清单不要执行」,看它生成的初始清单是否全是 pending;然后说「开始执行第一项」,看它是否把第一项改成 in_progress。这种分步指令能把状态机逼出来,比一次性跑完更容易看清行为。

5. 常见报错对照排查:从 401 到清单不显示逐条定位

排障的核心思路是「按调用链路分段」。链路是:配置加载 → 请求发出 → 参数校验 → 执行返回 → history 写入 → UI 渲染。每一段有各自的典型报错,对照源码能快速定位。

第一类,401 Unauthorized或invalid api key。这发生在请求发出阶段,和 Write-Todos 源码无关,是密钥问题。检查三处:环境变量是否真的 export 了(用echo $TAOTOKEN_API_KEY确认非空)、配置文件里的 key 是否有多余空格或引号、密钥是否已过期或被删除。注意不要把密钥写进会提交的文件里。

第二类,local proxy failed或连接超时。这类报错说明请求没到达服务端,常见原因是 baseUrl 写错(比如多写了/v1导致路径重复)、本机网络策略拦截、或者端口被占用。先 curl 直连https://taotoken.net/api看是否通,通了再查 CLI 配置。如果 curl 通而 CLI 不通,检查 CLI 是否读取了另一份配置文件。

第三类,reading 'choices'或Cannot read properties of undefined。这是响应解析阶段的报错,通常意味着服务端返回的结构和客户端预期不一致,比如返回了错误对象但客户端仍按成功结构解析。先看原始响应体(curl 复现),确认是 200 带choices还是 4xx 带error。如果是后者,按错误信息处理;如果是前者但字段缺失,检查模型名是否被服务端识别。

第四类,Invalid parameters: Only one task can be "in_progress" at a time.。这条直接来自validateToolParamValues,是业务规则拦截。出现它说明模型一次生成了多个 in_progress 条目。这不是 bug,是设计如此。处理方式是让模型重试,或者在提示里明确「一次只推进一个任务」。如果你在写自己的工具,可以借鉴这个思路:把业务不变量写成显式校验并给出可读错误信息,比让脏数据流进 UI 强。

第五类,清单完全不显示。这发生在 UI 渲染阶段,排查顺序是:先确认useWriteTodos为 true;再确认 history 里是否有tool_group项且resultDisplay含todos字段;最后确认TodoTray的useMemo是否找到了最新条目。源码里它是从 history 末尾倒序找第一个含todos的resultDisplay,如果历史记录被截断或结构变了,就找不到。可以在 CLI 的调试模式里打印 history 验证。

第六类,OAuth相关报错。如果你用的是需要 OAuth 的登录方式而非 API Key,token 过期会报这类错。处理方式是重新登录或改用 API Key 方式。源码里认证走的是统一客户端,工具层不感知认证细节,所以这类错一定在更外层。

第七类,模型不调用工具。没有报错,但清单永远不出现。检查模型是否支持 function calling、工具定义是否真的传给了模型(有些配置项会过滤工具列表)、以及用户指令是否足够明确。把「帮我重构」改成「请先用 write_todos_list 拆解任务再执行」,命中率会明显提升。

6. 把源码读成可复用的设计:下一步怎么动手

读到这里,你应该已经能把 Write-Todos 的四个文件串起来了。如果要把这套设计搬到自己的项目里,建议按这个顺序动手:先抄数据结构(三个类型,别加字段),再抄校验规则(单一 in_progress 这条最值得抄),然后抄双返回结构(文本给模型、结构化数据给 UI),最后抄 UI 的倒序检索逻辑。这四步里,校验规则是最容易被忽略但收益最高的,因为它把「模型可能生成脏数据」这个不确定性挡在了执行之前。

想继续深入源码,下一步可以看BaseDeclarativeTool的实现,理解 JSON Schema 校验是怎么和业务校验串起来的;再看uiState.history的写入时机,搞清楚工具结果是在哪一层被塞进历史的。这两个点吃透,你就能自己加一个新工具并让它出现在 UI 上。

实际动手时,建议先用模型对话接口把工具定义和请求跑通,确认服务端行为符合预期,再改 CLI 源码。模型对话入口在https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,遇到参数细节以文档为准。密钥管理和用量查看在控制台https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。如果你打算长期跑 Agent 类任务,Coding Plan 的额度模型更适合高频调用场景。

最后一个实操建议:改完源码后,先跑write-todos.test.ts确认没破坏原有校验,再跑Todo.test.tsx的快照测试。快照测试失败不一定是 bug,可能是你改了图标或布局,这时候人工确认差异再更新快照。测试通过后再启动 CLI 做端到端验证,顺序别反,否则你会分不清是源码问题还是配置问题。