数据截至 (上游 commit e2d772072efa)
规划与执行主循环(Tell)
30 秒导读: 你在终端里对 Plandex 说一句"帮我加个登录功能",服务端不会一口气把代码全吐出来。它先进入规划,把这句话拆成一张带文件的子任务清单;然后进入实现,一个子任务一次模型调用地往下做;每一次调用都是一条流,流里的文字被边收边解析,认出哪一段是"要写进某个文 件的代码"。本章讲的就是这台状态机 + 主循环:一个 prompt 怎么变成一串子任务、又怎么被一步步落地。
本章覆盖 server/model/plan 的 tell_* 系列。不覆盖两件事:①<PlandexBlock> 里的代码真正变成 diff 的构建过程(见 02-build-apply);②上下文/项目地图怎么被选出来加载(见 03-context-maps)。模型角色(architect / planner / coder)的分工见 04-models-roles。
1. 这是什么(零基础也能懂)
一句话定义: "Tell" 是 Plandex 服务端把用户一句话 prompt 变成一串真实文件改动的那条主流程。
它要解决的问题。 让 AI 改一个大项目的代码,不能靠一次对话就吐完——上下文放不下、模型也容易跑偏。得像人一样:先想清楚"要动哪些文件、分几步",再一步一步做。
Plandex 的做法,一句话: 把任务拆成阶段。
| 阶段 | 白话 | 模型在干嘛 |
|---|---|---|
| 规划 Planning | "先列个待办清单" | 只输出一个 ### Tasks 子任务列表,绝不写代码 |
| 实现 Implementation | "照着清单一条一条做" | 每次只做当前一条子任务,输出代码块 |
用起来什么样。 用户在 CLI 敲:
plandex tell "add a /health endpoint that returns 200 OK"
服务端随后可能先回一段规划:
### Tasks
1. Add health handler
Uses: `server/handlers/health.go`
2. Register the route
Uses: `server/routes.go`
<PlandexFinish/>
然后自动继续,进入实现,逐个子任务地输出真正的代码块(用 <PlandexBlock> 包住),直到清单全部标记为 done。
一句话直觉。 把它想成一个带 checklist 的施工队:工头(planner)先写施工清单,工人(coder)照单逐项施工,每干完一项在清单上打勾;打完最后一个勾,收工。本章讲的就是"清单怎么来、工人怎么被一次次叫回来、以及工人说的话怎么被翻译成实际动作"。
2. 顶层全景(它大概怎么转)
2.1 一次 Tell 的骨架
从 HTTP handler 到第一段模型输出,主干只有几跳。怎么读这张图:从上往下是控制流,execTellPlan 是会被反复回到的那个"迭代入口"。
HTTP handler (plans_exec.go)
│ modelPlan.Tell(TellParams{...})
▼
Tell() ──activatePlan──► go execTellPlan(iteration:0) ← 每一"步"都从这里进
│ │
│ (本step:决定阶段→组装system prompt→发起模型流)
▼ ▼
立即返回(异步) doTellRequest ──► listenStream(一条 SSE 流)
│ 逐 chunk: processChunk
▼
handleStreamFinished
│ 存回复/判定子任务是否完成
▼
willContinuePlan? ──yes──► execTellPlan(iteration+1) ↺
│
no ──► 收尾(build/finish)
关键在那条回边:execTellPlan → …流… → handleStreamFinished →(若该继续)execTellPlan(iteration+1)。一 次 Tell 不是一次模型调用,而是一串,每串一个 iteration。
2.2 各部件一句话职责
| 部件 | 干什么 | 文件 |
|---|---|---|
Tell / execTellPlan | 一"步"的总入口:装配状态、决定阶段、发请求 | tell_exec.go |
resolveCurrentStage | 两阶段状态机:这一步该规划还是实现 | tell_stage.go |
getTellSysPrompt | 按阶段拼 system prompt(规划/实现用不同 prompt) | tell_sys_prompt.go |
formatSubtasks / ParseSubtasks | 把子任务列表写进 prompt / 从回复里读出来 | tell_subtasks.go、parse/subtasks.go |
listenStream / processChunk | 收流、把文本解析成文件操作 | tell_stream_main.go、tell_stream_processor.go |
execStatusShouldContinue | 判"当前子任务做完没" | exec_status.go |
willContinuePlan | 判"整个计划要不要再来一步" | tell_stream_status.go |
storeOnFinished | 把回复、子任务状态、flags 落库+提交 git | tell_stream_store.go |
handleMissingFile | 模型要改一个不在上下文里的文件时,停流问用户 | tell_stream_processor.go / tell_missing_file.go |
2.3 主线走一遍(高层)
- 入口。 handler 调
modelPlan.Tell(...)(handlers/plans_exec.go:98),Tell激活 plan 后go execTellPlan,HTTP 立刻返回,后面全在后台 goroutine 里跑(tell_exec.go:58)。 - 定阶段。
execTellPlan每次先问resolveCurrentStage:根据"上一条消息是什么、有没有做过 plan"决定这一步是 Planning 还是 Implementation。 - 拼 prompt。 按阶段选不同 system prompt,并把当前子任务清单塞进去。
- 发流、解析。
doTellRequest开一条流,listenStream逐 chunk 交给processChunk,后者认出<PlandexBlock>代码块 → 变成"文件操作"排队去构建。 - 收尾判定。 流结束后
handleStreamFinished存回复、判子任务是否完成;willContinuePlan决定要不要execTellPlan(iteration+1)再来一步,还是收工。
3. 核心原理(逐个机制,由浅入深)
3.1 两阶段状态机:这一步该规划还是实现?
要解决的小问题。 每次 execTellPlan 被调用(无论是用户第一次说话,还是自动继续的第 N 步),都要先回答一个问题:这一步,模型该"想"还是该"做"? 答案不是存在某个变量里,而是每步现算——依据是上一条成功的会话消息。
思路。 状态藏在对话历史里。resolveCurrentStage(tell_stage.go:22)先拿到"上一条没出错、没被中断的 assistant/user 消息"(lastSuccessfulConvoMessage,tell_stage.go:11),再据此推断。
推断规则,从上到下:
| 情况 | 判定的阶段 | 依据(tell_stage.go) |
|---|---|---|
| 没有历史,或上一条是用户 prompt | Planning | isUserPrompt → tell_stage.go:56 |
上一条 assistant 消息 Flags.DidMakePlan == true | Implementation | tell_stage.go:60 |
上一条本身就在 TellStageImplementation | Implementation | tell_stage.go:63 |
| 其它 | Planning | tell_stage.go:66 |
关键 flag:DidMakePlan。 这是连接两阶段的开关。它不在这里设,而是在上一步存回复时设:只要那一步的回复里解析出了新子任务(hasExplicitTasks)或删了子任务,storeOnFinished 就把 flags.DidMakePlan = true(tell_stream_store.go:111-119)。于是——"上一步刚做完 plan"→ 这一步自动切到 Implementation。状态机就是这样靠一个落库的 flag 完成阶段跃迁的。
规划阶段还分两个 phase。 当 tellStage == Planning 时,还要再选一个 PlanningPhase(tell_stage.go:85):
PlanningPhaseContext—— 先自动挑上下文。仅当开了 auto-context、有项目地图、且这一步不是"刚做完 context"时进入(tell_stage.go:86)。这一 phase 用 architect 模型,让它根据项目地图挑出该加载哪些文件。细节在 03-context-maps。PlanningPhaseTasks—— 真正拆子任务。用 planner 模型,输出### Tasks。
三态合起来构成 CurrentStage{TellStage, PlanningPhase},存进 state.currentStage(tell_stage.go:97)。后面选模型、选 prompt、判是否继续,全看这个结构。
这一步用哪个模型,由阶段决定(tell_exec.go:181-204,再在 397-409 按 token 量二次细化):
| 阶段 / phase | 模型角色 |
|---|---|
| Planning · Context | architect |
| Planning · Tasks | planner |
| Implementation | coder |
3.2 子任务列表:清单如何驱动一步步实现
要解决的小问题。 "规划"产出的清单,得能回写进后续每一步的 prompt,让 coder 知道"总共几步、做到哪了、这步做哪个"。所以子任务要能双向流动:从回复里读出来 ↔ 写回进 prompt。
读出来:ParseSubtasks。 纯文本解析,不靠模型 function-calling。它在回复里找 ### Tasks 段,然后逐行扫(parse/subtasks.go:10):
^\d+\.\s(如1.)开头 = 一个新子任务的标题;Uses:开头 = 这个子任务需要的文件(去掉反引号,逗号分隔);- 其余行 = 追加到当前子任务的描述。
对应地还有 ParseRemoveSubtasks(parse/subtasks.go:91),扫 ### Remove Tasks 段,按精确标题删任务。
合并:checkNewSubtasks。 解析出的新任务不是直接覆盖,而是增量合并(tell_subtasks.go:85):已完成的任务保留;按标题去重,只加新的;然后把 currentSubtask 指向第一个未完成的子任务(tell_subtasks.go:156-163)。删除逻辑同理在 checkRemoveSubtasks(tell_subtasks.go:179)。
写回去:formatSubtasks。 每一步拼 prompt 时,把当前清单渲染成一段文本塞进 system prompt(tell_subtasks.go:14)。渲染出来大概长这样(带每项的 Done: yes/no 和当前项标记):
### LATEST PLAN TASKS ###
1. Add health handler
Uses: `server/handlers/health.go`
Done: yes
2. Register the route
Uses: `server/routes.go`
Done: no
Current subtask: yes
最妙的一处:同一段清单文本,尾部会按当前阶段追加一句"你现在不许做什么"的强约束(tell_subtasks.go:61-75)。在 Planning·Tasks 阶段追加"你在规划阶段,绝对不许写任何代码,只能加/删子任务";在 Context 阶段追加"你在 context 阶段,不许写代码也不许改子任务"。这是防止模型在规划时手痒直接开写的护栏。
清单如何"驱动"实现——闭环:
planner 输出 ### Tasks
│ ParseSubtasks / checkNewSubtasks
▼
state.subtasks = [t1(no), t2(no), ...] ; currentSubtask = 第一个 no
│ formatSubtasks 写进 coder 的 system prompt
▼
coder 实现 currentSubtask,输出 "**t1** has been completed"
│ execStatusShouldContinue 认出完成标记
▼
storeOnFinished: t1.IsFinished=true ; currentSubtask ← 下一个 no
│ willContinuePlan: 还有未完成 → 继续
▼
execTellPlan(iteration+1) ↺ 直到没有 no