DeepSeek Harness 的计划模式:进入、退出与「不许动手」的边界
先说清楚这篇要回答的那个问题:在 DeepSeek Harness 里开了计划模式,模型仍然把文件改了,这算不算 bug?
答案在 docs/subsystems/plan.md 的第一段就写死了:计划模式是软性指引(soft guidance)。原文接着写明,沙箱模式与审批策略各自独立强制限制,两者都不读写计划状态,因此部署需要分别配置它们。同一句话在包 README packages/plan/plan-mode/README.md 的开头、以及设计说明 .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md 的结尾都重复了一遍——设计说明最后一段的措辞是,一个无视指引的模型仍然可以做出修改,除非部署方独立配置了 sandbox、approval 或文件系统策略。
所以那不是 bug,是这个子系统被明确划出去的边界。搞清楚这一点之后,再顺着代码走一遍,你会发现计划模式真正花力气解决的问题其实是另一个:这个状态什么时候生效、怎么在重启后还认得。
先做一层限定,后面涉及命令与配置的地方都适用:该项目 README 自述处于开发者预览阶段(developer preview)并明确写明会有破坏兼容性的变更,本文提到的命令名、字段名与文案随时可能变。
这个包一共就这么大
packages/plan/ 这个组下面只有一个包,plan-mode/,组 README 的表格里就一行,ctx 键是 ctx.planMode。包内 src/ 四个文件:index.ts 477 行是主体,invariant.ts 48 行只做一件事(校验 plan/mode 事件里的 active 是不是布尔值,不是就 fail),types.ts 28 行是 plan 投影键的唯一声明处,client.ts 10 行是纯 re-export。tests/ 四个 spec 文件我们数出 78 个 it( 用例,其中 plan-mode.spec.ts 一个文件占 62 个。
477 行主体配 78 个用例,这个比例本身就值得留意;下面几节要走的路径,正是这些用例覆盖的分支。
一次 /plan 到底发生了什么
命令是在 index.ts 里通过 ctx.inject(['commands'], ...) 注册的(约 269 行起),所以没组合命令服务的部署根本不会有这个命令。注册的名字是 plan,描述 Enter or leave plan mode,输入提示 [off|message]。
handler 第一行就是 const message = rawInput.trim(),然后 if (message === 'off')。注意这是精确匹配:只有 trim 之后正好等于小写 off 这三个字母才是退出。你敲 /plan OFF,走的是下面那条分支——先 this.set(agent, true) 选中计划模式,再因为 message !== '' 把 OFF 这三个字母通过 agent.steer() 作为下一步的普通用户消息发给模型。也就是说,一个大写把「退出」变成了「进入并对模型说 OFF」。文档 docs/subsystems/plan.md 里用的措辞是「确切参数 off」,README 的措辞是「reserves the exact argument off」,两处都强调了 exact,但读文档时很容易滑过去。
set(agent, active) 的返回值是四选一:'committed' | 'queued' | 'cancelled' | 'noop',命令 handler 就靠它选文案:
| 返回值 | /plan off 的回执文案 |
|---|---|
committed | Plan mode off. |
queued | Leaving plan mode (applies from the next step). |
cancelled | Plan mode entry cancelled. |
noop | 折叠出的状态仍是激活时重复 queued 那句,否则 Plan mode is already inactive. |
最后那行 noop 分支上面有一条源码注释,说明为什么要重复 queued 的措辞:只有真正处于未激活的会话读起来才是幂等的。这类分支在测试里是有专门条目的:设计说明 .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md 的 Verification 一节把「inactive idempotence」(未激活时的幂等)与「pending-entry cancellation」(取消待生效的进入)并列写进了命令测试的覆盖清单。
状态存在哪:一个只进日志的事件
plan/mode({ active: boolean })是一条只记日志、整值替换的会话事件,在 index.ts 里通过 declare module 合并进 SessionEventMap,注释写明「最后一条 plan/mode 获胜」。读取靠 foldPlanMode(events, end?),函数体就是从头扫一遍、遇到 plan/mode 就覆盖 active,初值 false。
这个设计的直接后果,文档 docs/subsystems/plan.md 写得很直白:生效状态始终是会话日志的纯折叠,所以恢复、fork 与压缩(compaction)都不需要实时镜像就能还原它。同一处还写明这条事件绝不进入模型 transcript——模型不会在历史里看到「你现在是计划模式」这条记录,它看到的只有系统提示词里那段文本。
顺带一个已知限制,写在 README 的 Known Limitations 一节:fork 出来的 agent 继承已记录的计划状态,而新 spawn 的 agent 一律从未激活开始,创建时没有计划模式选项。
为什么切换总是「从下一步开始生效」
这是整个包最反直觉、也最值得读源码的一段。
set() 里先算 target(待生效的目标,没有就取当前折叠值),相同则直接 noop。接着调 hasOpenTurn(session.events)——这个私有函数从头扫日志,遇到 turn/start 置 open、turn/end 置 close,返回最终状态。有未闭合的轮次,就把选择塞进 pendingIntents 这个 WeakMap<Session, { active, narrate }>,返回 queued 或 cancelled;没有未闭合轮次(agent 空闲),才直接 session.append('plan/mode', { active }) 当场落盘,返回 committed。
落盘之后才 delete 待生效项,源码注释写明理由:只有 append 成功后才删除,这样一次失败的持久化写入留下的是可重试的选择,而不是被丢掉的选择。
真正的追加点是构造函数里注册的 agent/pre-step 监听器(约 205 行)。它的形状是先 await next() 拿到下游的 decision,只有在 decision.kind !== 'reject'、signal 未中止、且确实存在待生效项时才继续;追加动作包在 try/catch 里,失败只打一条 ctx.logger.warn(消息前缀 dsh-plan-mode: failed to append selected plan mode at step start)并原样返回 decision——追加失败不能阻塞这一轮,选择继续等下一个被接受的轮内 pre-step。
这里有一处文档与源码的措辞差异,照实记一笔:docs/subsystems/plan.md 把这个监听器描述为「前置(prepend)注册的 agent/pre-step 监听器」,而 packages/plan/plan-mode/src/index.ts 里这次 ctx.on('agent/pre-step', ...) 调用没有传第三个参数,仓库里其它地方(例如 packages/core/system-prompt/src/invariant.ts)用的是显式的 { prepend: true }。两处不一致,以实读的源码为准;这里只陈述差异,不推断原因。
还有一个「状态丢失」的口子写在 README 里:在某轮最后一个被接受的 pre-step 之后作出的选择只存在于进程内(就是那个 WeakMap),如果进程在下一个被接受的轮内 pre-step 之前退出,这个选择就没了,README 明确写着 UI 必须重新施加一次。
模型什么时候会被告知模式变了?私有方法 narration() 里:先用 planModeAtLastHeader() 找出最后一条 request/header 时刻的计划状态,只有当它与目标状态不同时才生成一条插件来源的 user/message 通知,文案是固定两句之一——The user switched this session to plan mode. 或 The user switched this session back to the default mode.。第一次请求头出现之前不叙述,来回翻最后净变化为零的也不叙述。
plan:policy 那段文本,才是「不许动手」的全部
配置类型只有一个字段:
interface PlanModeConfig {
section: string
}
resolveConfig() 三种情况直接抛错、不做静默忽略:不是字符串(PlanModeConfig needs a string \section`)、trim 后为空、存在任何未知键。激活期间这段文本以 order 50 渲染成 plan:policy` 系统提示词段落;未激活时贡献空字符串。
那么部署方实际写了什么?apps/cli/config/agent-presets/ 下的 code、standard、cordis 三个预设各自都有 plan-mode 行,section 文本相同,其中两句最值得注意:
Explore first. Use non-mutating reads, searches, static analysis, and checks to
ground the plan in the actual repository. Do not edit or write files, change
configuration, run formatters or code generation that rewrites tracked files,
commit, or otherwise carry out the plan.
A user's conversational agreement — including an answer confirming something you
asked — approves nothing and does not end plan mode; fold the confirmed decision
into the plan and submit it through exit_plan_mode.
第一段就是那句「不许动手」的原文出处——它是提示词里的一句英文,不是任何拦截器。第二段是很多人会踩的:你在对话里回一句「行,就这么干」,按这段配置的口径不构成批准。
同一段配置里还有一句解释了另一个反直觉现象:工具目录在两种模式下保持一致(原文 The tool catalog stays the same across modes for request-cache stability),这些计划模式规则覆盖后面任何鼓励使用修改类工具的工具描述。另外它明确要求不要用 todo_write 来跟踪规划阶段。
一处措辞差异也照实记:packages/bundle/base/cordis.patch.yml 里同一段 section 的对应句写的是 those tools remain listed only to keep the request shape stable,而三个 CLI 预设写的是 those tools remain listed to keep the tool catalog unchanged。两处不一致,说完就停。
退出:一个从不下线的工具
exit_plan_mode 这个工具名在源码里是导出的常量 EXIT_PLAN_MODE,注释写明它在计划模式未激活时仍然保持注册,目的是让请求工具目录在模式切换时保持稳定。docs/tool-catalog.md 里这一行的说法一致。它只有一个必填参数 plan(string)。
execute 路径上按顺序有这么几道闸:
- 没有调用方 agent → 抛错;
foldPlanMode为 false(不在计划模式)→ 抛exit_plan_mode is only available in plan mode;- 计划文本校验:
/^#\s+\S/.test(args.plan.trim())。这条正则要求 trim 之后第一个字符就是一级#,后面跟空白再跟非空白。用## 二级标题开头会被拒,前面垫一段说明文字也会被拒。有意思的是同文件里的firstHeading()用的是/^#{1,6}\s+(.+?)\s*$/,一到六级都认——但它只用于presentCall的卡片标题,不参与校验。两个正则、两种用途,别看混; - 拿不到
userQuestions通道 → 抛错,文案里直接建议让用户手动切换会话模式; - 评审问题带 id
plan-review、两个选项Approve与Keep planning,并声明了intent: { kind: 'plan-review', approve: APPROVE_LABEL }; - 评审期间插件被 dispose(服务重载)→ 抛错要求重新呈交;源码注释给的理由是:一次评审可能比这个插件的 fiber 活得久,没有它的 pre-step 监听器,批准后的选择永远追加不上,所以宁可失败、留在计划模式;
- 同意判定极严:必须恰好一条 id 为
plan-review的回答、selected长度为 1 且等于Approve、且custom为undefined。带自由文本的 Approve 按反馈处理,不算同意。
用户中途关掉评审去说别的话,会被单独识别(UserQuestionError 且 code === 'ASK_CANCELLED'),返回给模型的是一句专门的话:留在计划模式、停在这里、等用户发消息。index.ts 里这段 catch 的源码注释写明了为什么要单独处理:通用通道消息会提到 ask_user_question,而模型压根没调过那个工具。README 对应的说法是,被关掉的评审会照实报给模型,让它留在计划模式等用户的消息,其余的评审失败则保留通道自己的消息。
批准之后并不是立刻退出,而是 this.pendingIntents.set(agent.session, { active: false, narrate: false })。narrate: false 是因为工具结果自己已经说明了这次转换。于是计划指引会在 assistant 当前这一批工具调用的剩余部分继续生效,直到下一个被接受的 pre-step 才真正落盘。同一批预设的 section 里另有一句要求,原文是 Make exit_plan_mode the only and final tool call in that assistant response——把 exit_plan_mode 作为该次 assistant 回复里唯一且最后一个工具调用。两处摆在一起看即可,本文不推断其中的因果。
两个 pending 不是同一个东西
服务方法 get(agent) 返回的 { active, pending? },其中 pending 来自进程内那个 WeakMap;而 plan 投影单元(在 ctx.sessionProjections 被组合时才注册)的 view 里,pending 是 state.wanted !== null && state.wanted !== state.active,wanted 由日志里的 command/run(name 为 plan)折叠而来,args.trim() !== 'off' 为 true 即视为想进入,args 为 undefined 时直接返回原状态。README 把投影的 pending 称为「纯粹的重放量」,主机重启、另开标签页、冷读都能只从日志恢复。
一个是进程内的、会随进程退出丢失;一个是从日志重算的。同一个词,两个来源,排查 UI 状态不一致时先分清你看的是哪个。
什么时候你会发现它没装
packages/bundle/web-app/cordis.patch.yml 里有一行 - id: plan-mode 配 disabled: true;而 packages/bundle/base/cordis.patch.yml 与三个 CLI agent 预设里都有配好 section 的 plan-mode 行。这个包本身是可选的,docs/subsystems/plan.md 明写 agent loop 不依赖它——所以「我的部署里没有 /plan」首先该去查的是组合里到底有没有挂这个包、以及有没有挂命令服务,而不是去翻命令拼写。
至于开头那个问题的处置:如果你要的是「真的动不了文件」,按仓库文档的口径,得去配沙箱与审批策略,那是另外两条独立的轴,它们不看计划状态。不要因为进了计划模式就认为执行被限制住了——这不是安全边界,设计说明里对「按名单过滤工具」这个替代方案的否决理由,最后一句写的正是「在出现具体消费者之前,计划模式是指引,不是安全边界」。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。