我让 AI Agent 先别改代码,它怎么还是动手了?

1,503 阅读40分钟

🚀 欢迎来到「不用框架,手搓 AI Agent」系列第六篇。没读过前文也没关系,你只需要知道:我们已经有了一个能读文件、改代码、执行命令的最小 AI Agent。

你有没有遇到过这种情况:明明已经特意告诉 AI Agent:

“先别改代码,先把项目理解清楚。”

结果它读了两三个文件,转头就调用 edit_file 开始改了。

我们都说了“先别改”,它怎么还是动手了?

这并不是我们手搓的 Agent 才会遇到的问题。

只要一个 Coding Agent 同时拥有读取项目、修改文件和执行命令的能力,它就必须判断:什么时候还在了解项目,什么时候可以开始动手。

如果 Harness 一开始就把 read_fileedit_filebash 这些工具全部交给模型,那么模型读完几个相关文件后,很可能认为信息已经足够,接着选择最能推进任务的动作——直接修改代码。

Claude Code、Codex、Cursor 这类成熟的编程 Agent,同样需要处理这个问题。因此,它们都提供了 Plan Mode 或类似的规划流程。

具体实现虽然不完全相同,但核心思路很接近:先读取和搜索项目,形成实施计划,暂停并等待用户确认,批准后再修改代码。

Plan Mode 要解决的,正是“什么时候只能调查,什么时候可以开始执行”这个能力边界问题。

回到我们自己的 Agent。

在前面的章节里,我们已经让它学会了读取文件、搜索项目、修改代码和执行命令,也实现了能够持续调用工具的 Agent Loop。

但目前所有工具从一开始就是可用的,整个循环里也没有“规划阶段”“等待审批”和“执行阶段”的区别。

所以用户说的“先别改,先看看”,目前仍然只是一句提示词,而不是 Harness 真正执行的限制。

这一篇,我们就来补上这块缺失的能力:给 Agent 加入真正的 Plan Mode 和任务清单。它会先在只读状态下调查项目、提交计划并等待审批;用户批准后,再恢复修改文件和执行命令的能力,并通过任务清单持续记录执行进度。

先看看这个系列最终会做出什么

Kapture 2026-07-13 at 10.07.08.gif

上面演示的,不是只完成这一篇后的 Agent,而是经过整个系列逐步打磨后,我们最终会亲手做出的“完全体”。

它会具备一个编程 Agent 应有的大部分核心能力:理解项目、规划任务、等待审批、修改代码、调用工具,并持续跟踪执行进度。

这一篇不会一次做完全部功能,我们会先聚焦完全体中的两块能力:Plan Mode任务清单

系列目录

  1. 不用框架,手搓 AI Agent:(一)先让它跑起来
  2. 不用 LangChain,手搓 AI Agent:给大模型装上“手”,让它自己读项目文件
  3. 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
  4. Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
  5. 不用 LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
  6. 本文:我让 AI Agent 先别改代码,它怎么还是动手了?
  7. 更多实战持续更新中……

🚀 本节配套源码:powercode 👈 点它

如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐

为了不让大段完整源码打断阅读,正文只保留用来解释原理的关键片段。需要复制完整文件时,可以直接点击每一步后面的 GitHub 源码链接。

一个很典型的翻车现场

01-agent-wrong-edit.png

上周五,隔壁同事探过头来:

“登录这块能不能顺手改一下?现在新旧两套接口看着有点乱,最好再补几个测试。”

“行,我先让 Agent 看看,应该挺快。”

你把需求复制给 Agent,然后起身接了杯水。等你回来,它已经开始干活了:

read_file
  src/login.ts

read_file
  src/routes.ts

edit_file
  src/login.ts

edit_file
  src/...

没过多久,Agent 就回复:

“登录模块已经重构完成。”

同事扫了一眼改动,马上发现了问题:

“等一下,这个文件早就不用了。线上走的是另外一层,而且那个旧接口不能删,客户端还在调。”

你赶紧往下翻,越看越不对:

真正的登录入口还没读
旧接口有哪些调用方也没搜全
兼容逻辑被当成旧代码删了
测试命令一次都没跑

问题不在于 Agent 不会写代码,而在于信息还没查完整时,我们就已经把 write_fileedit_filebash 都交给了它。

你说的“先看看”,是想让它先把项目摸清楚;但嘴上说“先看看”,并不等于 Harness 真的限制它只能看。

如果是把任务交给同事,我们多半会补一句:

“你先别改。把入口、调用方、兼容方案、可能踩的坑,还有准备怎么测都列一下。我看完没问题,你再动手。”

这其实就是 Plan Mode 要解决的问题。

在 Claude Code、Codex、Cursor 这类成熟的编程 Agent 产品中,都可以看到类似的规划模式。具体实现不尽相同,但核心思路很接近:规划时保留读取和搜索能力,暂时收起会修改项目的工具;等计划通过审批,再恢复写文件和执行命令的能力。

提示词里的“先别改”只是一句建议,Plan Mode 才是由 Harness 真正执行的能力边界。

那么今天,我们就来给自己的 Agent 实现一个 Plan Mode。

02-plan-mode-tool-gate-bg.png

Plan Mode 到底是什么

Plan Mode 可以理解成 Agent 的“只做方案,暂不施工”阶段。

我们先以 Claude Code 为例。

在可以执行修改的模式下,Claude Code 的目标是把任务直接做完:

image.png

切换到 Plan Mode 之后,Claude Code 会暂时收紧修改源码的能力,先读取和搜索项目、形成计划,然后等待用户审批。

这时它的目标不是完成代码,而是先回答:

真正需要改什么
涉及哪些文件和调用关系
准备按什么顺序实现
有哪些风险
最后怎样验证

因此,进入 Plan Mode 后,Agent 应该经历三个阶段:

image.png

所以 Plan Mode 不是另一种大模型,也不只是生成一个 PLAN.md

它首先是 Harness 中的一种运行状态:

image.png

计划内容由大模型生成,但“批准前不能修改”和“提交后必须等待”应该由 Harness 保证。

为什么 Agent 总想马上改代码

03-direct-edit-vs-plan.png

这里先澄清一下:大模型不是真的“性格冲动”。

它只是会根据当前目标、上下文和可用工具,选择一个看起来最能推进任务的动作。

当用户说:

帮我重构登录模块

Harness 又同时提供以下工具:

read_file
write_file
edit_file
bash

那么从模型的角度看,读取和修改都是合法动作。它读到一两个相关文件后,很容易判断“信息已经够了”,下一步自然就是调用 edit_file

Agent Loop 还会继续推动它向前:

image.png

整个循环里,没有任何一步要求它:

先证明自己已经找全入口
先列出影响范围
先把方案交给用户
等用户批准后才能继续

所以它不是故意乱来,而是在我们提供的 Harness 里,“直接开改”本来就是一条畅通无阻的路径。

即使在提示词里加一句:

请先了解清楚,再修改代码。

模型仍然需要自己判断什么叫“了解清楚”。它可能读完两个文件,就认为这个条件已经满足了。

所以Plan Mode 做的第一件事,就是从能力层把这条路先堵住:

Plan Mode 可用
├── read_file
├── list_files
├── search_files
└── submit_plan

Plan Mode 不可用
├── write_file
├── edit_file
└── bash

这时即使模型觉得“可以开始改了”,它也没有修改文件的工具。它能做的只有继续读取、继续搜索,或者调用 submit_plan 把方案交出来。

不过,收走写工具只解决了“不能提前修改”。

要让它真的形成“先了解,再确认”的流程,还需要另外两层:

规划提示词
  要求先探索项目,计划中写清步骤、风险和验证方式

审批状态机
  submit_plan 后暂停 Agent Loop,必须等待用户选择

所以这一篇实现的并不是一个工具开关,而是三层配合:

image.png

这个骨架并不是我们凭空发明的。Claude Code、Cursor、Codex 和 Oh My Pi 的 Plan Mode,都能看到类似的“先规划、再审批、后执行”思路,只是每家把它落在了不同的地方:

产品和我们相似的地方主要差异
Claude CodePlan Mode 会先调查项目、提交方案,批准前不修改源码它把 Plan 直接做成了权限模式,并提供了多种批准后的执行方式
Cursor先搜索代码库、询问关键问题、生成计划,然后等待用户批准它的计划可以作为 Markdown 直接编辑和保存
Codex同样强调先探索真实项目,再输出可执行的完整方案它更像一种规划协作协议;公开资料并没有说内部也使用名为 submit_plan 的工具
Oh My Pi有独立的 Plan 角色、计划产物和批准入口还支持批准后清空、压缩或保留规划上下文,比我们的第一版复杂得多
Pi 原版可以通过扩展实现这套流程核心本身明确没有内置 Plan Mode,不能说它默认就是这样实现的

因此,我们这一版更准确的说法是:

用 Claude Code 式的权限边界和审批流程做骨架,再加入 Codex 式的规划提示词。

我们自己定义的 submit_plan,可以理解为一个明确的“规划完成”事件。它的作用和其他 Agent 里的“退出 Plan Mode”或“提交计划”很像,但不代表这些产品内部都使用了同名工具。

我们希望 Agent 也遵守同样的流程:

image.png

最终的终端体验会是这样:

> /plan
已进入 Plan Mode,只能读取和搜索项目。

> 重构登录模块,兼容旧接口并补上测试

Agent 读取目录、搜索调用关系、分析风险……

请选择:
1. 批准并执行
2. 继续修改计划
3. 取消

> 1
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-重构登录模块/

批准之前,Agent 看不到写文件和执行命令的工具;批准之后,它才开始修改代码,并用 TODO 记录自己做到哪一步。

这就是这一篇要实现的 Plan Mode:

它不是一句“请先想清楚”的提示词,而是一套由 Harness 控制的“只读—审批—执行”状态机。

这一篇要实现什么

基于第五篇完成后的 powercode,我们增加六项能力:

  1. codeplan 两种模式;
  2. /plan/code/status 三个命令;
  3. Plan Mode 只能读取、列目录和搜索;
  4. 模型必须通过 submit_plan 提交计划标题、完整正文和执行步骤;
  5. Agent Loop 提交计划后暂停,等待用户审批;
  6. 批准后进入 Code Mode,在 .powercode/tasks/<plan-name>/ 中生成 PLAN.mdTODO.md 再执行。

本文要实现的六项 Plan Mode 能力

最后的工具边界是:

Plan Mode
├── read_file       # 读取指定文件的内容
├── list_files       # 查看项目目录结构
├── search_files     # 在项目中搜索文本和代码
└── submit_plan      # 提交完整计划并进入审批阶段

Code Mode
├── read_file       # 读取指定文件的内容
├── list_files       # 查看项目目录结构
├── search_files     # 在项目中搜索文本和代码
├── write_file      # 创建文件或写入完整内容
├── edit_file       # 对已有文件进行定向修改
└── bash            # 执行构建、测试等终端命令

为什么 Plan Mode 不提供 Bash?

不是因为 Bash 完全不能用,而是这个 Demo 的规划阶段根本不需要它。

Plan Mode 只需要完成三件事:

read_file
  只读取一个文件

list_files
  只列出目录结构

search_files
  只搜索文本

这三个工具已经足够让 Agent 看清目录结构、找到相关代码并读取文件内容。

如果再把 Bash 交给 Plan Mode,我们就还要判断每条 Shell 命令到底只是查询,还是可能写文件、删文件或访问网络。这会引入很多和本文主线无关的边界处理。

像 Codex 和 Claude Code 这类成熟产品,可以继续通过命令解析、权限规则、用户审批和沙箱来约束 Bash。但对我们的第一版来说,最清楚的选择就是:

Plan Mode 只提供规划真正需要的专用查询工具,暂时不提供 Bash。


先说明仓库和目录

本文继续使用主项目 powercode

PowerCode 源码、冻结 Demo 与 Agent 工作目录的边界

第五篇结束后,我们已经把当时的代码冻结在:

powercode/demos/05-session-compaction

这个目录是第五篇的独立 Demo,用来回看 Session 和 Compactor,不再继续修改。

第六篇要改的是主项目源码:

powercode/
├── provider.json
├── src/                         # 本文继续开发的 Agent 源码
│   ├── agent.ts
│   ├── main.ts
│   ├── plan.ts                  # 本文新增
│   ├── plan-files.ts            # 本文新增
│   ├── options.ts               # 本文新增
│   ├── context/
│   │   ├── session.ts
│   │   └── compactor.ts
│   └── tools/
│       ├── read-file.ts
│       ├── write-file.ts
│       ├── edit-file.ts
│       ├── bash.ts
│       ├── list-files.ts        # 本文新增
│       ├── search-files.ts      # 本文新增
│       └── submit-plan.ts       # 本文新增
├── demos/
│   └── 05-session-compaction/   # 第五篇完成态,不再修改
└── workspace/
    └── task-board/              # 交给 Agent 规划和执行的练习目录
        └── .powercode/          # 运行 Plan 后生成的任务记录
            └── tasks/
                └── <plan-name>/
                    ├── PLAN.md
                    └── TODO.md

三个目录不要混在一起:

powercode/src
  我们正在开发的 Agent Harness

powercode/demos/05-session-compaction
  第五篇的冻结版本

powercode/workspace/task-board
  Agent 实际读取、搜索和修改的练习项目

后面的代码都基于第五篇完成后的主项目继续添加。没有读过前文也没关系:本文会讲清每个关键修改,完整文件则放在 GitHub 中供你直接对照。

先说结论:Plan 和 TODO 是两个概念,但可以放在同一份文档

Plan 和 TODO 经常一起出现,但它们解决的不是同一个问题。

Plan 与 TODO 在批准前后的职责对比

Plan
  决定准备怎么做
  发生在执行之前
  需要用户审核

TODO
  记录现在做到哪一步
  发生在计划批准之后
  随执行进度更新

有些 Agent 会把两者放在同一份文档里。例如 Codex 生成的计划文档中,就可能直接出现这样的勾选清单:

- [ ] 找到登录模块的真实入口
- [ ] 梳理旧接口的调用方
- [ ] 实现兼容层
- [ ] 补充测试并验证

在计划还没有批准时,这些勾选项表示的是“准备怎么做”,本质上仍然是 Plan 的任务分解。如果 Agent 在批准后继续更新勾选状态,这份文档就同时承担了 TODO 的职责。

所以真正需要分开的是两种状态:

批准前
  这是待审核的计划清单

批准后
  这是正在执行的进度清单

至于物理上是一个文件还是两个文件,属于 Harness 的实现选择。这一篇为了让职责更清楚,会为每次批准的计划创建一个独立任务目录,再在里面保存两份文件:

.powercode/tasks/<plan-name>/
├── PLAN.md
│   保存用户批准的方案,尽量保持稳定

└── TODO.md
    从计划步骤生成,执行时持续更新

这样不同任务的计划和进度不会互相覆盖,以后也更容易增加任务历史和中断恢复。

完整流程应该是:

Code Mode
  ↓ 用户输入 /plan
Plan Mode
  ↓ 只读项目、搜索代码、澄清需求
提交 Plan
  ↓
等待用户审批
  ├── 继续修改计划
  ├── 取消
  └── 批准
        ↓
      Code Mode
        ↓
      创建 .powercode/tasks/<plan-name>/
        ↓
      写入 PLAN.md 和 TODO.md
        ↓
      修改代码、逐项验证、更新 TODO

这和上一篇的上下文压缩也不是一回事:

Session + Compactor
  解决“这一轮模型能看到什么”

Plan Mode
  解决“用户批准前,Agent 能做什么”

任务目录 + PLAN.md + TODO.md
  解决“这是哪次任务、批准了什么,以及执行到哪里”

三个问题,应该放在 Harness 的不同位置处理。


为什么不能只写一句“请先规划”

最简单的做法,是在 system prompt 里写:

请先分析需求,不要修改代码。

这可以提高模型先思考的概率,但它不是可靠的权限边界。

如果 Harness 仍然把下面这些工具全部交给模型:

read_file
write_file
edit_file
bash

模型依然有能力修改项目。提示词只是在劝它不要写,并没有真正收走写权限。

Claude Code 官方把 Plan Mode 放在 permission mode 体系里:Plan 阶段允许读取和探索,但不修改源码;计划完成后先交给用户审核,批准后再切换到执行权限。

参考资料:Claude Code Permission Modes

这正好能说明 Harness 的作用:

大模型负责生成方案,Harness 负责决定当前状态、提供哪些工具,以及什么时候暂停等待用户。

这一篇采用的组合是:

Claude Code 式权限状态机
  +
更明确的规划提示词
  +
批准后的 TODO 执行进度

第一版暂时不实现独立 Plan 模型、Plan 子 Agent、上下文清空或全屏审核器。这些都是生产级 Plan Mode 可以继续增加的能力:

  • 独立 Plan 模型:规划阶段专门切换到更擅长推理的模型,批准后再换回更快或更便宜的执行模型。它本质上是模型路由,Agent Loop 还是同一个。
  • Plan 子 Agent:主 Agent 不亲自做完整规划,而是启动一个拥有独立上下文和工具的子 Agent 研究项目,最后把方案交回主 Agent。这会引入多 Agent 调度和结果交接。
  • 上下文清空:用户批准后,不把规划时的整段对话继续交给执行模型,只保留已批准的 Plan 和必要信息。清空的是消息上下文,不是删除项目文件。
  • 全屏审核器:不在终端里直接打印一大段 Plan,而是打开独立界面,让用户滚动查看、编辑、批注、批准或退回计划。这属于 TUI 交互增强。

独立 Plan 模型和 Plan 子 Agent 容易混淆:前者只是“换一个大模型思考”,后者是“再启动一个 Agent 完成规划任务”。

本文的第一版使用同一个模型、同一个 Session 和普通 CLI 审批菜单。我们先把最重要的状态边界做对。


第一步:建立 Plan Mode 状态机

这一步先不涉及大模型和工具调用。我们只做一个专门保存 Plan Mode 运行状态的对象,让 Harness 随时能回答三个问题:

当前是 Plan Mode 还是 Code Mode?
计划正在生成,还是已经等待审批?
当前保存的是待审批计划,还是已批准计划?

这些答案之后会决定 Agent 能看到哪些工具,以及 Agent Loop 是继续运行还是暂停等待用户。

等下的代码中还会出现一个 PlanDraft。先不要被这个名字吓到,它只是 Harness 内部保存“待审批计划”的数据格式:title 用来创建任务目录,content 保存完整的 Markdown 计划正文,steps 用来生成和更新 TODO。

先新建 src/plan.ts

完整实现放在 GitHub:src/plan.ts

1. modestatus 分别管什么

这里故意把 modestatus 分开:

mode
  控制 Agent 当前拥有哪些能力
  例如 Plan Mode 看不到 write_file 和 bash

status
  记录 Agent Loop 已经走到哪个阶段
  例如正在规划、等待审批或正在执行

为什么不只定义一个布尔值?

planMode: boolean

因为 planMode = true 只能告诉我们“当前在 Plan Mode”,却无法表达下面两种完全不同的情况:

mode = plan
status = planning
  Agent 还在读项目、搜代码和生成方案

mode = plan
status = waiting_for_approval
  计划已经提交,Agent Loop 必须暂停

两种状态下的工具边界虽然一样,但前者还可以继续调用模型,后者只能等待用户选择。

2. Harness 怎样接住模型生成的计划

前面我们一直在说,计划需要包含目标、实现步骤、风险和验证方式。

这些内容最终就是一份普通的 Markdown:

# 重构登录模块

## 目标

在兼容旧接口的前提下重构登录流程。

## 实现步骤

1. 找到登录入口
2. 梳理旧接口调用方
3. 增加兼容层

## 风险

旧版本客户端可能仍然调用原接口。

## 验证方式

运行登录测试,并手动验证旧客户端。

这里先把一个容易混淆的地方说清楚:

大模型可以直接理解整份 Markdown。我们不需要先把风险、验证方式等章节提取出来,再重新交给它。

用户批准以后,Harness 可以把完整计划放进模型上下文,也可以告诉模型 PLAN.md 的路径,让它自己读取。两种方式都可以。

不管是 Harness 主动把计划放进去,还是 Agent 使用 read_file 读取,计划正文最终都要进入模型上下文,大模型才能理解和执行。PLAN.md 的真正价值,是把批准后的计划持久化:即使上下文后来被压缩或丢失,Agent 仍然可以重新读取它。

既然如此,为什么还要定义 PlanDraft

因为这一篇还希望 Harness 自动完成两件事情:

根据计划标题创建任务目录
根据执行步骤生成 TODO.md

如果让 Harness 自己从自由格式的 Markdown 中猜标题和步骤,解析会很不稳定。但目标、背景、风险和验证说明只需要给用户与大模型阅读,没有必要都拆成字段。

因此第一版只结构化程序真正需要处理的部分:

export interface PlanDraft {
  title: string;
  content: string;
  steps: string[];
}

interface 只是 TypeScript 用来描述数据形状的方式。PlanDraft 不是一个新 Agent,也不是一份额外的草稿文件,它只是一个保存待审批计划的 JavaScript 对象格式。

为什么名字里有 Draft

因为模型刚提交时,这份计划还没有经过用户批准,它只能算是“待审核的计划草案”。批准之前,用户还可以要求模型修改或者取消它。

模型产生的数据会这样流转:

模型调用 submit_plan
  ↓ 提交 title、content、steps
Harness 得到 PlanDraft
  ↓
保存到 pendingPlan
  ↓ 用户批准
移入 approvedPlan
  ↓
生成 PLAN.md 和 TODO.md

现在再看每个字段,就比较容易理解了:

title
  计划名称,后面用来生成任务目录名

content
  完整的 Markdown 计划正文
  目标、方案、风险和验证方式都写在这里

steps
  从 content 中提炼出的可执行步骤
  用来生成和更新 TODO.md

注意,这里不是 Harness 先保存 content,再从 Markdown 里解析出 steps。模型调用 submit_plan 时会同时提交这三个字段,Harness 只负责检查它们是否为非空字符串或数组。

这里会有少量重复:content 里会写实现方案,steps 又把可执行项单独列了一遍。这是有意为之。

content
  给用户审核,也给大模型执行

steps
  给 Harness 生成和跟踪 TODO

Plan Mode 提示词会要求两者保持一致。第一版只能约束格式和非空,不能从语义上证明两者完全相同;但这已经足够完成教学 Demo。这样既保留了 Markdown 的表达能力,又不需要 Harness 自己解析整份文档。

3. pendingPlanapprovedPlan 有什么区别

这两个字段保存的都是 PlanDraft,但信任级别不同:

pendingPlan
  模型已经提交,但用户还没批准
  它可以被要求修改,也可以被取消

approvedPlan
  用户已经批准
  Agent 可以把它当成后续执行的正式依据

批准时做的关键动作,就是把同一份计划从“待审批区”移到“已批准区”:

pendingPlan
  ↓ 用户批准
approvedPlan

这也是 approvePlan() 里面这几行代码的含义:

const plan = this.pendingPlan;

this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;

注意,“模型生成了计划”并不代表“计划已经生效”。只有用户批准以后,它才会进入 approvedPlan

4. 这些方法实际上是状态转换入口

AgentState 并不负责调用大模型,也不负责写入 PLAN.md。它只负责保存状态,并确保状态只能通过下面这些入口改变:

  • enterPlan():进入 Plan Mode,把状态切到 planning,并清理上一次未完成的计划。
  • enterCode():手动回到 Code Mode,相当于放弃当前规划流程。
  • submitPlan():只允许在 Plan Mode 调用,保存待审批计划,并进入 waiting_for_approval
  • refinePlan():用户要求修改时,从等待审批返回 planning
  • approvePlan():把待审批计划转成已批准计划,切换到 Code Mode 并开始执行。
  • cancelPlan():取消计划,清空两个计划字段并回到 idle
  • finishExecution():执行结束后,把 executing 恢复为 idle,并清除只在本次执行期间使用的 approvedPlan。已经落盘的 PLAN.mdTODO.md 不受影响。

把它们串起来,就能看到完整的状态机:

Plan Mode 中 mode 与 status 共同组成的状态机

这张图可以分成两部分看:先看方框表示的“当前状态”,再看箭头表示的“状态怎样发生变化”。

先看四个状态方框

绿色方框表示 Code Mode,可以修改文件和执行命令;橙色方框表示 Plan Mode,只能读取、搜索和提交计划。

每个方框中又记录了两项信息:

mode
  code 表示 Code Mode
  plan 表示 Plan Mode
  它决定当前可以使用哪些工具

status
  idle、planning、waiting_for_approval、executing
  它决定 Agent Loop 正在空闲、规划、等待审批,还是执行任务

所以,mode 回答的是“Agent 现在拥有哪些能力”,status 回答的是“Agent 现在进行到了哪一步”。

再看方框之间的箭头

箭头上的中文表示是谁触发了这次变化,例如“用户批准”或“模型提交计划”;下面的函数名表示 Harness 实际调用的状态转换入口,例如 approvePlan()submitPlan()

也就是说,状态不能由模型随便改,而只能经过图中这些明确的方法发生变化。

例如,模型调用 submitPlan() 后,只是从 planning 进入 waiting_for_approval,此时仍然处于 Plan Mode,写工具不会恢复。接下来 Agent Loop 必须停住,只能等待用户选择继续修改、取消或批准。

只有用户选择批准,approvePlan() 才会同时完成两件事:把 mode 切回 code,再把 status 切到 executing。这时 Harness 才创建任务文件并开始执行。

submitPlan()refinePlan()approvePlan() 里面主动检查当前状态,是为了防止跳过正常流程。例如,在 Code Mode 下直接提交计划,或在没有待审批计划时直接批准,都应该立即报错。

5. formatPlan() 只负责补上标题

content 已经是完整的 Markdown 计划正文,所以 formatPlan() 不需要理解或重新排列里面的章节。

title + content
  ↓ formatPlan()
可以展示和保存的完整 PLAN.md

它不会批准计划、不会切换模式,也不会写文件,只负责把标题和正文拼在一起。目标、方案、风险和验证方式仍然保留在模型生成的 content 中。


第二步:给 CLI 增加模式命令

Claude Code 可以通过 Shift+Tab 切换 Plan Mode。

我们的第一版先使用三个文本命令:

/plan       进入 Plan Mode
/code       返回 Code Mode
/status     查看当前模式和状态

原因很简单:当前项目使用 readline.question() 做逐行输入。如果现在就捕获 Shift+Tab,还需要处理原始按键、终端转义序列和输入框刷新,文章会从 Agent Harness 跑到 TUI 实现上。

但快捷键和文本命令最终应该调用同一个状态切换函数:

/plan ──────────┐
                ├── state.enterPlan()
Shift+Tab ──────┘

所以第一版先把状态机做好,以后增加快捷键不需要修改 Agent 核心。

新建 src/options.ts

完整实现放在 GitHub:src/options.ts

parseCliOptions() 让用户在启动 PowerCode 时可以决定两件事:Agent 一开始进入哪种模式,以及它要操作哪个项目目录。

本文使用下面这条命令启动:

npm start -- --plan -dir ./workspace/task-board

其中,npm start 后面的第一个 -- 只是 npm 的参数分隔符,表示把后面的内容继续交给 PowerCode。真正属于 PowerCode 的参数是:

--plan
  启动后直接进入 Plan Mode

-dir ./workspace/task-board
  把 workspace/task-board 设为 Agent 的工作目录

这两个参数彼此独立:--plan 决定初始模式,-dir 决定工具可以读取、搜索和修改的目录。本文把它们放在一起,是为了让 Agent 启动后立即规划练习项目,同时避免它拿 powercode 自己的源码做实验。

本文显式使用 -dir,是因为我们目前还在 PowerCode 的源码目录中通过 npm start 调试 CLI。将来 PowerCode 发布为 npm 全局命令后,用户只需要进入自己的项目再运行 powercodeprocess.cwd() 对应的当前项目目录就会自动成为 Agent 的工作空间;-dir 只作为从其他位置指定项目时的可选覆盖参数保留。

也就是说,未来最常见的使用方式应该是:

cd ~/projects/my-app
powercode --plan

只有不方便先进入目标项目时,才需要显式指定:

powercode --plan -dir ~/projects/my-app

第三步:增加两个真正只读的探索工具

第五篇已经有 read_file,但读取项目还需要两项基础能力:

Plan Mode 使用列目录、搜索、读文件和提交计划完成只读探索

list_files
  看目录结构

search_files
  按关键词查代码

新建 src/tools/list-files.ts

完整实现放在 GitHub:src/tools/list-files.ts

先别急着看递归,我们先看这个工具从输入到输出到底做了什么。

假设项目中有下面这些文件:

src/
├── agent.ts
├── main.ts
└── tools/
    ├── list-files.ts
    └── read-file.ts

模型想查看 src 目录时,会发起这样的工具调用:

{
  "path": "src"
}

list_files 执行完成后,返回给模型的不是 JavaScript 数组,也不是文件内容,而是一段按行排列的普通文本:

src/agent.ts
src/main.ts
src/tools/
src/tools/list-files.ts
src/tools/read-file.ts

目录会在结尾加上 /,方便模型区分它和普通文件。模型拿到这张“项目地图”以后,才会决定接下来用 read_file 读取哪个文件,或者用 search_files 搜索哪个关键词。

如果没有传 path,工具默认从项目根目录 . 开始;如果目录是空的,就返回“目录为空。”。所以它的职责非常单一:只告诉模型项目里有哪些文件和目录,不读取文件正文。

这个工具要做的事情,其实就是从指定目录开始,一层一层打开子目录,再把看到的文件路径记录下来:

模型传入 path
  ↓
把 path 解析成工作目录内的绝对路径
  ↓
使用 readdir() 读取当前目录
  ↓
记录文件和子目录的相对路径
  ↓
遇到子目录就继续向下遍历
  ↓
把结果按行返回给模型

核心是下面这个递归过程:

const entries = await readdir(directory, {
  withFileTypes: true,
});

for (const entry of entries) {
  if (entry.isSymbolicLink()) {
    continue;
  }

  if (
    entry.isDirectory() &&
    SKIPPED_DIRECTORIES.has(entry.name)
  ) {
    continue;
  }

  const fullPath = join(directory, entry.name);
  const displayPath = toDisplayPath(
    relative(this.workDir, fullPath),
  );

  items.push(
    entry.isDirectory()
      ? `${displayPath}/`
      : displayPath,
  );

  if (entry.isDirectory()) {
    await walk(fullPath);
  }
}

withFileTypes: true 会让 readdir() 同时告诉我们每一项是文件还是目录。遇到目录时,walk() 再进去读取下一层;返回给模型时使用的是相对于工作目录的路径,而不是用户电脑上的绝对路径。

代码还做了三层收口:

  • 跳过符号链接,避免遍历到工作目录之外或形成循环;
  • 跳过 .git.powercodedistnode_modules,减少无关内容;
  • 最多返回 200 项,避免一个大项目的目录列表一次占满模型上下文。

这里仍然复用了第四篇的 resolveInWorkDir()。它会先确认模型传入的 path 仍然位于 workDir 之内,再允许 walk() 开始遍历。

即使工具只读,也必须限制路径不能逃出工作目录。只读不等于可以读取用户电脑上的任意文件。

新建 src/tools/search-files.ts

完整实现放在 GitHub:src/tools/search-files.ts

这个搜索工具没有正则表达式、管道和重定向,只做一件明确的事:在工作目录内查找文本。

这比把完整 Bash 交给 Plan Mode 更容易解释,也更容易测试。

这两个工具都会跳过 .powercode。里面放的是 PowerCode 生成的 PLAN.mdTODO.md,不是项目源码;规划时把这些旧任务记录也搜出来,只会干扰模型。进入执行阶段后,Agent 仍然可以按照审批消息里的准确路径,用 read_fileedit_file 读取、更新它们。


第四步:让 Registry 同时负责“隐藏”和“拦截”

只是不把写工具的定义发给模型,还不够完整。

Registry 在工具定义和执行前的双重权限校验

完整文件可以对照 GitHub:src/tools/registry.ts。下面只看这一篇需要修改的两个方法。

模型通常不会调用一个没有见过的工具,但 Harness 仍然应该在真正执行前再次检查:

第一层
  不把 write_file、edit_file、bash 放进 tools 参数

第二层
  即使模型生成了这些工具名,Registry 也拒绝执行

修改 src/tools/registry.ts 中的两个方法:

  1. getDefinitions() 增加 allowedNames 参数,只把当前模式允许的工具定义发给模型;
  2. execute() 增加同一个参数,在真正执行前再次拦截不允许的工具。

register() 和其他代码保持不变,只需要替换下面两个方法:

getDefinitions(
  allowedNames?: ReadonlySet<string>,
): OpenAI.Chat.Completions.ChatCompletionTool[] {
  return [...this.tools.values()]
    .filter(
      (tool) =>
        !allowedNames || allowedNames.has(tool.name),
    )
    .map((tool) => tool.definition);
}

async execute(
  name: string,
  argumentsJson: string,
  allowedNames?: ReadonlySet<string>,
): Promise<string> {
  if (allowedNames && !allowedNames.has(name)) {
    throw new Error(
      `当前模式不允许调用工具:${name}`,
    );
  }

  const tool = this.tools.get(name);

  if (!tool) {
    throw new Error(`找不到工具:${name}`);
  }

  return tool.execute(argumentsJson);
}

到这里,Plan Mode 的“只读”已经不只是提示词了。

真正的边界是:

const PLAN_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "submit_plan",
]);

write_fileedit_filebash 不在集合里,所以模型看不到,Registry 也不会执行。


第五步:增加 submit_plan 工具

如果计划只是普通 Markdown 文本,Agent Loop 很难准确判断:

submit_plan 将模型文本转换为等待审批的工作流事件

模型是在解释思路?
还是已经提交最终计划?
现在该继续调用模型?
还是应该暂停等待用户?

所以增加一个明确的控制工具:

submit_plan
  模型表示“计划已经完成”
  Harness 把状态切到 waiting_for_approval
  Agent Loop 暂停

新建 src/tools/submit-plan.ts

完整实现放在 GitHub:src/tools/submit-plan.ts

先看它从输入到输出做了什么。

模型完成规划后,会调用 submit_plan,同时传入三个字段:

{
  "title": "重构登录流程",
  "content": "## 目标\n在兼容旧接口的前提下重构登录流程。\n\n## 实现方案\n先找到登录入口和调用方,再增加兼容层。\n\n## 风险\n旧客户端可能仍在调用原接口。\n\n## 验证方式\n运行登录测试,并验证旧接口。",
  "steps": [
    "找到登录入口和旧接口调用方",
    "增加兼容层并修改登录流程",
    "运行登录测试并验证旧接口"
  ]
}

这里的三个字段各有去处:

title
  后面用来生成任务目录名

content
  用户将要审核的完整 Markdown 计划
  批准后写入 PLAN.md

steps
  批准后转换成 TODO.md 中的待办项

工具收到参数后,会先检查 titlecontent 是不是非空字符串,再检查 steps 是不是一个非空字符串数组。格式不对就直接报错,不会把一份残缺的计划放进状态机。

真正执行计划提交的核心只有三行:

async execute(argumentsJson: string): Promise<string> {
  const plan = parsePlan(argumentsJson);
  this.state.submitPlan(plan);

  return "计划已提交,等待用户审批。";
}

parsePlan() 把模型传来的 JSON 转成 PlanDraftstate.submitPlan() 则完成两件事:

pendingPlan = plan
status = waiting_for_approval

因此,工具直接返回给 Agent Loop 的结果只是一句话:

计划已提交,等待用户审批。

但这句话不是最终展示给用户的计划。Agent Loop 发现状态已经变成 waiting_for_approval 后,会停止继续请求模型,再从 pendingPlan 中取出完整计划交给 CLI 展示。

把整个过程连起来就是:

模型调用 submit_plan(title, content, steps)
  ↓
parsePlan() 校验并生成 PlanDraft
  ↓
state.submitPlan() 保存到 pendingPlan
  ↓
状态切换为 waiting_for_approval
  ↓
Agent Loop 暂停
  ↓
CLI 展示完整计划,等待用户选择

不过要注意:这几步不是全部写在 submit-plan.ts 里的,而是由三个文件接力完成的。这里先把接力关系讲清楚,agent.tsmain.ts 的具体修改会在后面的第七步和第九步完成。

1. submit-plan.ts:只负责提交计划

const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);

执行到这里,计划被放进 pendingPlan,状态也变成了 waiting_for_approval。但 submit_plan 自己不会停止 Agent Loop,更不会显示审批菜单。

2. agent.ts:发现状态变化后退出 Agent Loop

本轮工具全部执行完以后,Agent.run() 会检查当前状态:

if (
  this.state.getStatus() ===
  "waiting_for_approval"
) {
  const plan = this.state.getPendingPlan();

  if (!plan) {
    throw new Error("计划状态异常。");
  }

  return formatPlan(plan);
}

关键是最后的 return。它会直接结束当前的 run(),因此外层 for (let step...) 不会再进入下一轮,也就不会继续请求模型。

所以这里说的“Agent Loop 暂停”,准确来说是:

停止继续调用模型
  ↓
把完整计划返回给 CLI
  ↓
Node.js 进程继续运行,等待用户输入

3. main.ts:打印计划并打开审批菜单

CLI 原本就是按下面的顺序调用:

await runPrompt(value);
await reviewPendingPlan();

runPrompt() 会打印 Agent.run() 返回的完整计划;接着 reviewPendingPlan() 发现状态仍然是 waiting_for_approval,便显示三个选项:

1. 批准并执行
2. 继续修改计划
3. 取消

然后它通过 readline.question() 等待用户选择。到这里,“提交计划 → 停止 Agent Loop → CLI 展示计划并等待审批”才算真正闭环。

这个工具不会修改用户源码,也不会创建 PLAN.mdTODO.md。那两份文件只有在用户批准计划、Harness 切回 Code Mode 以后才会生成。


第六步:给 Plan Mode 注入专属提示词

现在权限边界已经由 Harness 保证,提示词只负责提高计划质量。

完整文件可以对照 GitHub:src/agent.ts。正文继续保留与 Plan Mode 有关的关键片段。

修改 src/agent.ts 时,先补上状态相关的导入:

import {
  AgentState,
  formatPlan,
} from "./plan.ts";

然后把原来的单条 system prompt 拆成三段:

const CORE_SYSTEM_PROMPT = `
你是 power-code,一个研发助手。
请优先读取真实文件;修改后主动运行命令验证结果;请使用中文回答。
所有文件路径都相对于当前工作目录,不能操作工作目录之外的文件。
`;

const PLAN_MODE_PROMPT = `

# 当前模式:Plan Mode

你现在只能研究项目和制定计划,不能修改文件或执行命令。

请按以下顺序工作:

1. 先使用 list_files、search_files 和 read_file 了解真实项目。
2. 能从项目中找到的答案,不要询问用户。
3. 只有关键需求或取舍无法从项目确认时,才向用户提出问题并等待回答。
4. 不要把普通猜测写成已经确定的事实。
5. content 必须是一份完整的 Markdown 计划正文,写清目标、方案、风险和验证方式,不重复 title 对应的一级标题。
6. steps 必须从 content 的实施方案中提炼,按执行顺序排列,并包含必要的验证步骤。
7. content 和 steps 不能出现两套不同的方案。
8. 计划完成后必须调用 submit_plan,不要只输出一段普通 Markdown。
`;

const EXECUTION_PROMPT = `

# 当前模式:Code Mode

下面的计划已经由用户批准。

批准消息会给出本次任务的 PLAN.md 和 TODO.md 路径。
请先读取这两份文件,然后按照 TODO.md 从上到下执行。

- 完成一个有结果的步骤后,立即把对应的 - [ ] 更新为 - [x]。
- 修改代码但还没有完成对应验证时,不能勾选包含验证工作的步骤。
- 遇到错误时先读取本次任务的 TODO.md 确认当前位置,再修复并重新验证。
- 不得擅自改变已经批准的目标和范围。
`;

这里借鉴的是一种很实用的规划顺序:

先探索
  ↓
能从代码确认的先自己确认
  ↓
只询问真正需要用户决定的问题
  ↓
生成可以直接执行的计划

注意,提示词不是安全边界。

即使模型忽略了“不能修改文件”,Plan Mode 也拿不到写工具;这才是前面修改 Registry 的意义。


第七步:根据模式动态提供工具

继续修改 src/agent.ts

Plan Mode 与 Code Mode 拥有不同的动态工具集合

先处理一个容易被忽略的地方。第五篇为了演示最小版 Agent Loop,把最大执行轮数设成了 8:

const MAX_STEPS = 8;

对于“读取项目、生成计划、修改多个文件、逐项验证”这样的长任务,8 轮通常不够。把它调整为:

const MAX_STEPS = 30;

这里的“一轮”指的是模型完成一次思考并返回结果,不等于只调用一个工具。模型可能在同一轮里连续调用多个 read_filewrite_file,所以日志中会出现多个“第 8 轮”。

MAX_STEPS 仍然要保留,它是防止 Agent 因为重复搜索、反复修改而无限循环的安全上限。30 也不是固定标准,只是更适合这一篇长任务 Demo 的起点;以后可以再把它做成配置项。

先在 Agent 类外定义两组工具名,可以放在前面的提示词常量后面:

const PLAN_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "submit_plan",
]);

const CODE_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "write_file",
  "edit_file",
  "bash",
]);

然后给 Agent 增加 AgentState

constructor(
  private readonly client: ChatClient,
  private readonly registry: Registry,
  private readonly session: Session,
  private readonly state: AgentState,
) {
  this.compactor = new Compactor(
    client,
    client.getContextWindow(),
  );
}

接着把下面两个私有方法放进 Agent 类内部,位置就在 constructor 后面、run() 前面:

export class Agent {
  constructor(...) {
    ...
  }

  // 在这里加入 getAllowedToolNames()
  // 在这里加入 buildSystemPrompt()

  async run(...) {
    ...
  }
}

具体代码如下:

private getAllowedToolNames(): ReadonlySet<string> {
  return this.state.getMode() === "plan"
    ? PLAN_TOOL_NAMES
    : CODE_TOOL_NAMES;
}

private buildSystemPrompt(): string {
  if (this.state.getMode() === "plan") {
    return CORE_SYSTEM_PROMPT + PLAN_MODE_PROMPT;
  }

  const approvedPlan = this.state.getApprovedPlan();

  if (approvedPlan) {
    return (
      CORE_SYSTEM_PROMPT +
      EXECUTION_PROMPT +
      `\n\n${formatPlan(approvedPlan)}`
    );
  }

  return CORE_SYSTEM_PROMPT;
}

这里正是“把整份计划直接交给大模型”的位置。formatPlan(approvedPlan) 会把 title + content 原样加入执行阶段的 system prompt,大模型不需要依赖 Harness 解析风险或验证方式。后面再写入 PLAN.md,是为了把批准结果持久化,方便执行中重读和以后回看。

调用模型时,不再直接获取全部工具:

const allowedToolNames =
  this.getAllowedToolNames();

const completion = await this.client.completeWithUsage(
  messages,
  this.registry.getDefinitions(allowedToolNames),
);

执行工具时也传入同一份集合:

result = await this.registry.execute(
  name,
  toolCall.function.arguments,
  allowedToolNames,
);

最后,在 run() 的外层 Agent Loop 中找到下面这段工具循环:

for (const toolCall of toolCalls) {
  // 执行工具,并把 tool result 写入 Session
}

把状态检查放在这个工具循环的右花括号后面、外层 for (let step...) 进入下一轮之前:

for (const toolCall of toolCalls) {
  // 原有的工具执行代码
}

// 加在这里:本轮工具已经全部执行完,
// 但还没有再次请求大模型。
if (
  this.state.getStatus() ===
  "waiting_for_approval"
) {
  const plan = this.state.getPendingPlan();

  if (!plan) {
    throw new Error("计划状态异常。");
  }

  return formatPlan(plan);
}

不要把它放到 for (const toolCall...) 里面,也不要放到整个 run() 外面。放在这里可以保证本轮所有工具结果已经写入 Session,同时在下一次请求模型前及时停住。

这样,模型调用 submit_plan 后,Agent Loop 不会继续让它自己批准自己,而是立刻返回 CLI。

单线程里,Agent Loop 怎么停下来等用户

Agent Loop 提交计划后返回 CLI 并等待用户审批

这里的“暂停”并不是把正在执行的 for 循环挂在内存里,同时开另一条线程询问用户。

先分清两个很容易混在一起的动作:

模型调用 submit_plan
  表示“计划已经写完,可以交给用户看了”

用户批准计划
  表示“我同意这个方案,现在可以执行了”

submit_plan 是模型发起的工具调用,不需要用户在这一刻输入内容。Plan Mode 提示词已经告诉模型:读完项目、计划写好以后,必须调用这个工具。

因此,agent.run() 启动 Agent Loop 后,可能会经历下面几轮:

第 1 轮:模型调用 list_files
第 2 轮:模型调用 search_files
第 3 轮:模型调用 read_file
第 4 轮:模型认为信息足够,调用 submit_plan

模型具体在第几轮提交并不固定。每一轮请求大模型时,它都可以选择继续探索,也可以返回一个 submit_plan 工具调用。Harness 收到第 4 轮的工具调用后,会像执行其他工具一样执行它:

const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);

这时只是把状态改成了 waiting_for_approval。等本轮工具执行结束,Agent Loop 走到下面这个固定检查点:

if (
  this.state.getStatus() ===
  "waiting_for_approval"
) {
  return formatPlan(plan);
}

条件成立,于是执行 return,本次 Agent Loop 到这里结束。随后 CLI 才会展示计划并询问用户,而不是在 submit_plan 工具内部和用户交互。

先把实际代码缩成最关键的两层:

async function runPrompt(value: string) {
  const answer = await agent.run(value); // ②
  console.log(`AI:${answer}`);           // ④
}

await runPrompt(value);                  // ①
await reviewPendingPlan();               // ⑤

假设用户输入“帮我重构登录流程”,程序会这样往下走:

  1. main.ts 执行第 ① 行,调用 runPrompt()。因为前面有 await,第 ⑤ 行暂时不会执行。
  2. runPrompt() 执行第 ② 行,调用 agent.run()。它也会停在这里,等待 Agent Loop 返回结果。
  3. Agent 读完项目并调用 submit_plan,状态变成 waiting_for_approval,随后执行:
return formatPlan(plan); // ③

这句 return 会结束本次 agent.run(),把完整计划交回第 ② 行的 answer,不是把 for 循环挂在那里。

  1. runPrompt() 拿到 answer,执行第 ④ 行打印计划,然后函数结束。
  2. 第 ① 行的 await 到此完成,main.ts 才继续执行第 ⑤ 行,进入 reviewPendingPlan() 并询问用户。

可以把它看成普通的嵌套函数调用,只是中间多了异步等待:

调用:main.ts → runPrompt() → agent.run()
返回:main.ts ← runPrompt() ← agent.run()

全部返回以后,main.ts 才调用 reviewPendingPlan()

整个过程仍然是单线程顺序执行的,没有 Agent Loop 和审批菜单同时运行。await 的意思就是“先等这个函数完成,再执行下一行”。

用户批准计划后,CLI 会再次调用:

await runPrompt(
  "计划已经批准,请开始执行……",
);

这会启动一次新的 agent.run(),而不是回到刚才那个已经结束的 for 循环。新的调用仍然使用同一个 AgentStateSession,因此它可以拿到已批准计划和前面的对话,再从 Code Mode 开始执行。

所以更准确地说:

不是“挂起旧 Agent Loop,批准后原地恢复”
而是“结束规划调用,等待审批,再发起新的执行调用”

src/agent.ts 的完整关键结构

整合后的 run() 主体如下:

async run(prompt: string): Promise<string> {
  if (
    this.state.getStatus() ===
    "waiting_for_approval"
  ) {
    throw new Error("当前计划正在等待审批。");
  }

  this.session.append({
    role: "user",
    content: prompt,
  });

  for (let step = 1; step <= MAX_STEPS; step += 1) {
    const memory =
      await this.compactor.buildWorkingMemory(
        this.session,
      );
    const allowedToolNames =
      this.getAllowedToolNames();

    const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
      [
        {
          role: "system",
          content: this.buildSystemPrompt(),
        },
        ...memory,
      ];

    const completion =
      await this.client.completeWithUsage(
        messages,
        this.registry.getDefinitions(
          allowedToolNames,
        ),
      );
    const message = completion.message;

    if (!message) {
      throw new Error("模型没有返回消息。");
    }

    this.session.append(message);

    if (
      completion.totalTokens !== undefined &&
      completion.totalTokens > 0
    ) {
      this.session.saveUsage(
        completion.totalTokens,
      );
    }

    const toolCalls = message.tool_calls ?? [];

    if (toolCalls.length === 0) {
      this.state.finishExecution();

      return (
        message.content ?? "模型没有返回文本内容。"
      );
    }

    for (const toolCall of toolCalls) {
      if (toolCall.type !== "function") {
        throw new Error(
          `暂时不支持工具类型:${toolCall.type}`,
        );
      }

      const name = toolCall.function.name;
      console.log(
        `第 ${step} 轮:AI 调用 ${name}`,
      );

      let result: string;

      try {
        result = await this.registry.execute(
          name,
          toolCall.function.arguments,
          allowedToolNames,
        );
        console.log(`✓ ${name} 执行完成\n`);
      } catch (error) {
        const reason =
          error instanceof Error
            ? error.message
            : String(error);
        result = `工具执行失败:${reason}`;
        console.log(`✗ ${result}\n`);
      }

      this.session.append({
        role: "tool",
        tool_call_id: toolCall.id,
        content: result,
      });
    }

    if (
      this.state.getStatus() ===
      "waiting_for_approval"
    ) {
      const plan = this.state.getPendingPlan();

      if (!plan) {
        throw new Error("计划状态异常。");
      }

      return formatPlan(plan);
    }
  }

  throw new Error(
    `执行超过 ${MAX_STEPS} 轮,已停止。`,
  );
}

原来的 Session 和 Compactor 不需要删除。

Plan 阶段读取的项目内容仍然进入当前会话,过长时仍然由 Compactor 构造工作记忆。我们只是改变了每一轮能使用的工具。


第八步:为每个计划创建独立任务目录

为什么不在 Plan Mode 一开始就创建独立的 TODO.md

用户批准计划后 Harness 创建独立任务目录和计划文件

因为这时方案还没有批准。

计划本身完全可以包含未勾选的步骤清单,Codex 就会采用这种表达方式。但在本文的双文件设计里,TODO.md 代表已经进入执行阶段的正式进度。

如果用户要求继续修改计划或直接取消,工作目录里就不应该出现一份看起来已经开始执行的 TODO.md

所以顺序是:

模型提交 Plan
  ↓
CLI 展示给用户
  ↓
用户批准
  ↓
Harness 根据 Plan 标题生成安全目录名
  ↓
Harness 创建 .powercode/tasks/<plan-name>/
  ↓
Harness 写入 PLAN.md 和 TODO.md

任务目录名不能直接使用模型返回的原始标题。标题里可能有空格、斜杠、.. 或其他不适合进入路径的字符,同名计划也可能相互覆盖。

所以 Harness 需要先把标题转换成安全名称,再加上时间标识:

计划标题
  重构登录模块,兼容旧接口

任务目录
  .powercode/tasks/20260817-143012000-重构登录模块-兼容旧接口/

这个 .powercode 位于用户通过 -dir 指定的执行目录中,不是固定写进 Harness 自己的源码目录。本文的启动命令使用 -dir ./workspace/task-board,因此实际位置是:

powercode/workspace/task-board/.powercode/tasks/<plan-name>/

新建 src/plan-files.ts

完整实现放在 GitHub:src/plan-files.ts

PLAN.md 保存完整的 content,因此目标、背景、风险和验证方式都不会丢失。TODO.md 只根据 steps 生成;如果某项验证必须实际执行,就应该把它作为一个明确步骤放进 steps

这里使用 Unicode 字母和数字白名单处理标题,斜杠、..、标点和连续空格都会被替换掉。时间部分精确到毫秒,用来降低同名任务发生覆盖的可能性。

这里的写入不是模型在 Plan Mode 调用 write_file

它发生在用户点击批准之后,由 Harness 自己完成:

Plan Mode
  模型没有写工具

用户批准
  Harness 切到 Code Mode

Code Mode
  Harness 创建本次任务目录、PLAN.md 和 TODO.md
  Agent 开始执行

这条边界要在文章和代码里都说清楚。


第九步:在 CLI 里完成审批流程

最后修改 src/main.ts

CLI 审批菜单中批准、继续修改和取消的三条分支

完整文件可以对照 GitHub:src/main.ts。下面只保留参数接线、状态显示、审批菜单和命令处理这些关键修改。

先在原有导入旁边增加:

import { AgentState } from "./plan.ts";
import { saveApprovedPlan } from "./plan-files.ts";
import { ListFilesTool } from "./tools/list-files.ts";
import { SearchFilesTool } from "./tools/search-files.ts";
import { SubmitPlanTool } from "./tools/submit-plan.ts";
import { parseCliOptions } from "./options.ts";

再解析参数、注册工具和状态:

const options = parseCliOptions(
  process.argv.slice(2),
);
const workDir = resolve(process.cwd(), options.dir);
const state = new AgentState(
  options.plan ? "plan" : "code",
);
const registry = new Registry();

registry.register(new ReadFileTool(workDir));
registry.register(new ListFilesTool(workDir));
registry.register(new SearchFilesTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
registry.register(new SubmitPlanTool(state));

const agent = new Agent(
  client,
  registry,
  session,
  state,
);

printStatus()src/main.ts 的顶层辅助函数。把它放在 agent 创建完成之后、reviewPendingPlan() 和最下面的输入循环之前:

const state = new AgentState(...);
const registry = new Registry();
const agent = new Agent(...);

// 在这里加入 printStatus()

// 后面再定义 reviewPendingPlan()
// 最后才是 while (true) 输入循环

具体代码如下:

function printStatus(): void {
  console.log(`当前模式:${state.getMode()}`);
  console.log(`当前状态:${state.getStatus()}`);

  const tools =
    state.getMode() === "plan"
      ? "read_file, list_files, search_files, submit_plan"
      : "read_file, list_files, search_files, write_file, edit_file, bash";

  console.log(`可用工具:${tools}`);
}

再增加计划审批:

async function reviewPendingPlan(): Promise<void> {
  while (
    state.getStatus() ===
    "waiting_for_approval"
  ) {
    console.log(`
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
`);

    const choice = (
      await readline.question("选择:")
    ).trim();

    if (choice === "1") {
      const plan = state.approvePlan();

      const files = await saveApprovedPlan(
        workDir,
        plan,
      );

      console.log(
        "\n计划已批准,已进入 Code Mode。",
      );
      console.log(
        `已创建任务目录:${files.taskDir}\n`,
      );

      await runPrompt(
        `计划已经批准。
计划文件:${files.planPath}
执行清单:${files.todoPath}
请先读取这两份文件,从第一项未完成任务开始执行;每完成一项后立即更新这份 TODO.md。`,
      );
      return;
    }

    if (choice === "2") {
      const feedback = (
        await readline.question(
          "请输入计划修改意见:",
        )
      ).trim();

      if (!feedback) {
        console.log("修改意见不能为空。");
        continue;
      }

      state.refinePlan();

      await runPrompt(
        `请根据下面的反馈修改计划,并再次调用 submit_plan:\n${feedback}`,
      );

      continue;
    }

    if (choice === "3") {
      state.cancelPlan();
      console.log(
        "\n计划已取消,已返回 Code Mode。",
      );
      return;
    }

    console.log("请输入 1、2 或 3。");
  }
}

为什么这里用 while,而不是 if

if 只检查一次,while 则会在条件成立时反复执行:

判断 status 是否为 waiting_for_approval
  ├── 是:执行一次审批逻辑,然后重新判断
  └── 否:结束审批循环

所以,决定循环是否继续的是 while 的条件,不是 continue。即使没有 continue,代码执行到循环体末尾后,也会自动回到开头重新判断。

continue 的作用只是跳过当前迭代剩余的代码,提前回到条件判断:

没有 continue
  执行完本轮剩余代码,再判断 while 条件

遇到 continue
  跳过本轮剩余代码,立即判断 while 条件

放回审批流程就很好理解了。假设用户第一次要求修改计划,第二次再批准:

第 1 次迭代
  用户选择“继续修改”
  ↓
  refinePlan():status 变成 planning
  ↓
  await runPrompt():模型修改并重新提交计划
  ↓
  status 再次变成 waiting_for_approval
  ↓
  continue:回到 while 开头

第 2 次迭代
  while 条件仍然成立,再次显示审批菜单
  ↓
  用户选择“批准”
  ↓
  approvePlan():status 变成 executing
  ↓
  return:结束 reviewPendingPlan()

当前代码使用 continue,是为了在“继续修改”处理完后跳过下面的取消判断和错误提示。即使删掉它,while 仍然可能进入下一次迭代,只是中间会继续执行本轮剩余代码。

如果用户第一次就批准或取消,函数会直接 return,审批循环实际上只执行一遍。循环里的 await readline.question() 会等待用户输入,因此它也不是一直占用 CPU 的死循环。

最后在原来的输入循环中处理命令:

while (true) {
  const value = (
    await readline.question("> ")
  ).trim();

  if (!value) continue;
  if (value === "exit" || value === "quit") {
    break;
  }

  if (value === "/plan") {
    state.enterPlan();
    console.log(
      "已进入 Plan Mode,只能读取和搜索项目。",
    );
    continue;
  }

  if (value === "/code") {
    state.enterCode();
    console.log("已进入 Code Mode。");
    continue;
  }

  if (value === "/status") {
    printStatus();
    continue;
  }

  try {
    await runPrompt(value);
    await reviewPendingPlan();
  } catch (error) {
    const message =
      error instanceof Error
        ? error.message
        : String(error);
    console.error(`本轮执行失败:${message}`);
  }
}

现在三个命令真正对应 Harness 状态:

/plan
  切换状态
  收走写工具

/code
  返回普通模式
  恢复写工具

/status
  查看模式、阶段和当前工具面

它们不是发给大模型的普通聊天内容。


跑一次完整流程

从进入 Plan Mode 到批准、执行和验证的完整流程

准备一个练习目录,例如:

workspace/task-board/
└── src/
    └── ceshi.ts

启动项目:

npm run build
npm start -- --plan -dir ./workspace/task-board

先查看状态:

> /status

当前模式:plan
当前状态:planning
可用工具:read_file, list_files, search_files, submit_plan

输入任务:

请把 src/ceshi.ts 改造成一个可以直接运行的小型待办演示。
先读取项目并给出计划,不要马上修改。

这一阶段应该看到类似的工具调用:

list_files
  ↓
read_file
  ↓
search_files
  ↓
submit_plan

不应该出现:

write_file
edit_file
bash

计划提交后,CLI 展示完整 Plan:

# 增加命令行待办演示

## 目标

把现有脚本改造成可以直接运行的待办展示,同时保持实现简单。

## 实现步骤

1. 阅读并保留现有入口结构。
2. 增加待办数据和格式化输出。
3. 运行 node src/ceshi.ts。
4. 确认终端输出包含全部待办项。

## 风险

- 当前文件扩展名与 Node 运行方式可能不兼容。

## 验证方式

- 运行 node src/ceshi.ts。
- 确认终端输出包含全部待办项。

然后出现审批菜单:

请选择:
1. 批准并执行
2. 继续修改计划
3. 取消

选择 2 时,Agent 仍然处于 Plan Mode,只能继续读取、搜索和重新提交计划。

选择 1 时:

计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-增加命令行待办演示/

用户指定的执行目录中会出现:

workspace/task-board/
├── src/
│   └── ceshi.ts
└── .powercode/
    └── tasks/
        └── 20260817-143012000-增加命令行待办演示/
            ├── PLAN.md
            └── TODO.md

这时才可能出现:

read_file
  .powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md

edit_file
  src/ceshi.ts

edit_file
  .powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md

bash
  node src/ceshi.ts

最终该任务目录中的 TODO.md 类似:

# 执行清单

## 实现步骤

- [x] 阅读并保留现有入口结构
- [x] 增加待办数据和格式化输出
- [x] 运行 node src/ceshi.ts
- [x] 确认终端输出包含全部待办项

这里有一条需要诚实说明:

“Plan Mode 不能写代码”由 Harness 强制保证;“完成一步就更新 TODO”在第一版里仍然主要依靠执行提示词。

如果以后想让 TODO 更新也变成强约束,可以再增加专门的 todo_update 工具,由 Harness 记录步骤状态,而不是让模型直接编辑 Markdown。


/code 和“批准并执行”有什么区别

两者都会进入 Code Mode,但含义不同。

手动输入 code 与批准计划并执行的区别

/code
  手动退出 Plan Mode
  不代表计划已经批准
  不自动生成 TODO

批准并执行
  明确批准当前 Plan
  创建本次任务目录
  保存 PLAN.md 和 TODO.md
  自动开始执行

正常流程应该使用“批准并执行”。

/code 更像一个逃生口:用户不想继续规划时,可以直接返回普通模式。


为什么第一版不做 Shift+Tab

不是不能做,而是它不属于这一篇最重要的部分。

当前 CLI 是逐行输入:

await readline.question("> ");

要捕获 Shift+Tab,通常需要监听按键事件,并处理终端发送的转义序列。

无论用户通过什么方式切换,底层最终都只是:

state.enterPlan();

或者:

state.enterCode();

因此正确的实现顺序是:

先实现状态机和权限边界
  ↓
再实现 /plan、/code
  ↓
最后把 Shift+Tab 映射到相同函数

等以后给 powercode 增加完整 TUI 时,再补快捷键会更自然。


几个容易踩的坑

实现 Plan Mode 时最容易踩的八个坑

1. 把 Plan Mode 写成一段提示词

如果模型仍然拿得到写工具,就不能说 Harness 已经进入只读模式。

正确做法是同时隐藏工具定义,并在执行前再次检查允许列表。

2. Plan 一提交就自动执行

这会让“审批”只剩下界面文字。

submit_plan 调用后必须把状态切到:

waiting_for_approval

然后暂停 Agent Loop。

3. 在批准前把计划清单当成执行进度

计划里可以有待勾选的任务分解,但在用户批准之前,它们仍然是方案的一部分,不应显示成“正在执行”。

本文选择在批准后才生成独立的 TODO.md。如果你选择 Codex 这种单文档方案,也要在状态上区分“待审批”和“执行中”。

4. 给 Plan Mode 整个 Bash

不能只检查命令是不是以 catgrepfind 开头。管道、重定向、命令替换和子进程都会让字符串判断变得复杂。

第一版使用专用只读工具更清楚。

5. 认为 Plan 和 TODO 必须存成两份文件

文件数量不是关键,状态边界才是。

可以像本文一样,用稳定的 PLAN.md 保存批准方案,用持续变化的 TODO.md 记录执行进度;也可以像 Codex 一样,在计划文档中直接保留勾选清单。

只要 Harness 清楚记录“待审批”和“执行中”,两种方案都是合理的。

6. 直接用 Plan 标题当文件夹名

Plan 标题由模型生成,不能直接当作可信的路径。必须先移除斜杠、.. 和特殊字符,限制长度,并加入时间或任务 ID 避免同名覆盖。

7. 让模型自己批准计划

模型可以生成计划,但批准权属于用户。submit_plan 之后必须回到 CLI,而不是再发一句“请确认计划可行”让模型自我判断。

8. 把敏感信息写进计划文件

.powercode/tasks/ 中的 PLAN.mdTODO.md 可能进入 Git。不要把 API Key、Cookie、访问令牌或完整敏感日志写进去。

如果这些只是本地运行状态,可以把 .powercode/ 加入 .gitignore;如果团队希望把计划当作项目文档保留,再有选择地提交。


到这里,我们真正给 Agent 增加了什么

Harness 通过规划、审批和执行三个阶段管理 Agent

AgentState
  保存 mode、status、待审批计划和已批准计划

Plan Mode
  只允许读取、列目录、搜索和提交计划

submit_plan
  把普通模型输出变成明确的工作流事件

用户审批
  决定继续修改、取消,还是切换到执行模式

任务目录
  根据 Plan 标题生成安全名称,隔离每次任务的计划和进度记录

PLAN.md + TODO.md
  分别保存已批准的方案和实时执行进度

最重要的变化不是多了两个 Markdown 文件,也不是多了 /plan 命令。

而是 Harness 第一次开始管理 Agent 的工作阶段:

规划阶段
  只能研究,不能执行

审批阶段
  Agent Loop 暂停,等待人类决定

执行阶段
  恢复写工具,按照批准的计划完成任务

这就是 Plan Mode 最值得学习的地方:

它不是一句“请先想清楚”的提示词,而是一套由 Harness 控制的只读、审批和执行状态机。


下一篇预告

现在,powercode 已经会先看项目、写计划,等我们批准以后再照着 TODO 一步步执行。

但真正拿它去改项目,很快又会碰到一个特别现实的问题:

bash 执行失败了
  ↓
Agent 换个参数又执行一次
  ↓
还是同样的错误
  ↓
继续重复,直到耗尽最大轮数

有时它还会在验证没有真正通过时,就提前把 TODO 标成完成。Plan Mode 能管住“先想清楚再动手”,却还管不住 Agent 在失败以后应该怎么自救。

所以下一篇,也就是这个系列的第七篇,咱们继续解决:

AI 连续报错怎么办?让它学会自救,而不是原地打转

我们会给 powercode 加上失败分类、重复失败检测和恢复提示。让 Harness 不只是把报错原样丢回模型,而是能告诉它:哪里失败了、是不是已经重复失败,以及下一步应该换什么方式继续。