Planning 模块实现原理
harness9 的 Planning 模块解决一个核心问题:如何让 Agent 在开始行动之前先想清楚,而不是一边做一边猜?
一般的 Agent 遇到复杂任务时,容易陷入"走一步看一步"的模式——它无法确定自己当前完成了多少、还剩多少、下一步做什么。Planning 模块为这个问题引入了一套轻量级的两阶段工作流:先规划(Plan Mode)、再执行(Exec Mode),通过结构化的任务列表(TodoStore)把不透明的推理过程变成可观察、可验证、可续跑的操作序列。
系统架构
internal/planning/
├── mode.go # PlanMode 枚举(Default / Plan / AutoEdit)
└── todo.go # TodoStore(线程安全)+ TodoItem / TodoStatus + FormatForInjection
internal/tools/
└── todo_write.go # todo_write 工具:读写 TodoStore + 批量防作弊校验
internal/engine/
└── agent_loop.go # filterReadOnlyTools(工具层权限过滤)+ Plan Mode prompt 注入
# runLoop 中 TodoStore 的加载 / 保存(跨会话持久化)
cmd/harness9/
├── tui_update.go # execPrompt / execContinuePrompt 常量
│ # dispatch() 启动推理流
│ # EventDone 中的 autoExecuting 续跑循环 + 停滞检测
│ # updateTodoBlock() 在对话流中追加 todo 快照
└── tui_view.go # renderTodoLines()(带图标的任务列表渲染)
# renderPlanReviewDialog()(Plan Mode 完成后的审查对话框)
# renderStatusBar() 中的任务进度展示工作流概览
Shift+Tab ──► Plan Mode 激活(状态栏变为琥珀黄)
用户输入任务描述 ──► dispatch(planModePrompt)
│
▼
engine.runLoop(filterReadOnlyTools 过滤写工具)
Agent 探索代码库,调用 todo_write 输出结构化计划
文字简述后自然停止
│
▼
TUI 展示审查对话框(planReviewing = true)
[1] 批准并自动执行 [2] 批准并逐步确认
[3] 继续修改计划 [4] 取消
│ 按 1 或 2
▼
planMode → Default,dispatch(execPrompt)
autoExecuting = true
│
▼
Agent 按清单逐项执行
┌─ 每项:in_progress → 实际工具调用 → completed ─┐
│ │
└────────────────────────────────────────────────┘
│ EventDone
▼
pending > 0 且 stuck < 3 → dispatch(execContinuePrompt)
pending == 0 → autoExecuting = false,完成
stuck ≥ 3 → 停止,提示手动干预PlanMode 枚举
// internal/planning/mode.go
type PlanMode int
const (
PlanModeDefault PlanMode = iota // 0:完整工具访问(默认)
PlanModePlan // 1:只读规划模式
PlanModeAutoEdit // 2:保留扩展位
)PlanMode 是一个整型枚举,Shift+Tab 在 TUI 中循环切换,Next() 通过 (m + 1) % 3 实现循环:
Default(0) → Plan(1) → AutoEdit(2) → Default(0) → ...为什么用枚举而非 bool? 未来可能有更多执行模式(如"静默模式"、"沙箱模式")。用枚举而非 isPlanMode bool 代价相同但扩展更自然。
eng.SetPlanMode(mode) 通过互斥锁保护写操作,runLoop 在启动时快照当前模式,确保整个推理循环内模式一致,不受 TUI goroutine 切换的影响:
// agent_loop.go — runLoop 入口
e.mu.RLock()
planMode := e.planMode // 快照,循环中不变
e.mu.RUnlock()TodoStore
TodoStore 是 Planning 模块的核心数据结构——一个线程安全的内存任务列表,采用**全量替换(atomic replace)**语义。
设计选择:全量替换 vs 增量更新
大多数任务管理系统使用增量 API(add / update / delete)。harness9 选择全量替换,原因如下:
- LLM 的自然输出形式是列表:每次 LLM 调用
todo_write时,它直接输出完整的当前清单,而不是"第 3 项的状态从 pending 改为 in_progress"这样的增量指令。全量替换与这种输出形式完全匹配。 - 避免状态不一致:增量 API 要求 LLM 维护对旧状态的准确认知,一旦出错(如 ID 拼写错误),状态就会发散。全量替换让每次写入都是确定性的快照。
- 实现简单:
Write只需copy(s.items, items),没有合并逻辑,没有冲突处理。
实现细节
// internal/planning/todo.go
type TodoStore struct {
mu sync.RWMutex
items []TodoItem
}
// Write 持有写锁,原子替换 items,返回替换后副本。
func (s *TodoStore) Write(items []TodoItem) []TodoItem {
s.mu.Lock()
defer s.mu.Unlock()
s.items = make([]TodoItem, len(items))
copy(s.items, items)
return s.copy()
}
// Read 持有读锁,返回副本(调用方可安全修改,不影响内部状态)。
func (s *TodoStore) Read() []TodoItem {
s.mu.RLock()
defer s.mu.RUnlock()
return s.copy()
}Write 采用双重 copy 策略:
- 第一次 copy(
s.items = make(...)+copy):内部存储与入参items解耦,防止调用方后续修改items影响TodoStore内部状态。 - 第二次 copy(
s.copy()):返回值与内部存储解耦,防止调用方修改返回值影响TodoStore。
相比直接赋值 s.items = items,双重 copy 确保调用方、内部存储与入参三者完全独立,消除潜在的数据竞争风险。
TodoItem 状态机
pending ──► in_progress ──► completed
│ │
└──► cancelled └──► cancelled四种状态对应 TodoStatus 字符串常量:
const (
TodoPending TodoStatus = "pending"
TodoInProgress TodoStatus = "in_progress"
TodoCompleted TodoStatus = "completed"
TodoCancelled TodoStatus = "cancelled"
)状态转换约束由 todo_write 工具(而非 TodoStore 本身)负责强制执行。TodoStore 对写入内容无校验——它只是一个无判断的存储层,业务约束在工具层表达。
todo_write 工具
todo_write 是 Planning 模块的核心工具,LLM 通过它读写任务列表。它在引擎工具注册表中注册,与 read_file、bash 等工具平级。
工具定义
// internal/tools/todo_write.go
func (t *TodoWriteTool) Definition() schema.ToolDefinition {
return schema.ToolDefinition{
Name: "todo_write",
Description: "维护当前会话的任务清单。" +
"提供 todos 数组时全量替换(atomic replace);省略 todos 时读取当前列表。\n" +
"当任务涉及 3 个或以上独立步骤时,在开始前调用此工具记录任务列表," +
"并在每完成一步后立即更新对应条目的 status 为 in_progress 或 completed。",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"todos": map[string]interface{}{
"type": "array",
"description": "完整的任务列表(全量替换)。省略此字段则仅读取当前列表。",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": ...,
"content": ...,
"status": {"type": "string", "enum": ["pending","in_progress","completed","cancelled"]},
},
},
},
},
},
}
}工具有两种调用模式:
| 调用方式 | 效果 |
|---|---|
传入 todos 数组 | 全量替换任务列表(写操作) |
省略 todos 字段 | 返回当前任务列表 JSON(读操作) |
防作弊校验:批量直接完成检测
LLM 可能在没有执行实际工作的情况下,在一次 todo_write 调用中将大量 pending 任务直接标记为 completed,伪造进度。这是"幻觉执行"——看起来完成了,实际上什么都没做。
原始 bug:在一次连续对话中,LLM 将 11 个任务中的 9 个一次性批量完成(2/11 → 11/11),没有对应的文件创建或 bash 执行操作。
防护策略:在一次 todo_write 调用中,最多允许 1 个任务从非 in_progress 状态直接跳转到 completed。超过 1 个视为批量作弊,拒绝写入。
// internal/tools/todo_write.go — Execute()
var directCompletions int
for _, item := range input.Todos {
if item.Status != planning.TodoCompleted {
continue
}
prior, exists := prevStatus[item.ID]
if !exists || prior == planning.TodoPending {
directCompletions++ // 新条目或 pending → completed,计入直接完成数
continue
}
if prior == planning.TodoCancelled {
return "", fmt.Errorf("任务 %q 已取消,不能直接标记为 completed;"+
"如需重新执行,请先将其恢复为 pending 或 in_progress。", item.ID)
}
// in_progress / completed → completed:合法,不计入
}
if directCompletions > 1 {
return "", fmt.Errorf(
"不允许在一次调用中将 %d 个任务直接标记为 completed(未经 in_progress)。"+
"请逐一处理:每次仅完成一项实际工作后更新该条目状态。",
directCompletions)
}为什么阈值是 1 而不是 0?
阈值 0(完全禁止 pending → completed)在续跑场景中过于严格:当 Agent 在一次续跑中完成了一项实际工作(调用了 bash 或 write_file),然后直接将对应 todo 标记为 completed 而没有中间经过 in_progress,属于合法行为——Agent 省略了 in_progress 中间步骤,但工作是真实完成的。把阈值设为 0 会导致 Agent 反复收到拒绝错误,打乱执行流程。
阈值 1 保留了对原始 bug 模式(大量批量完成)的防护,同时允许 Agent 在单项工作后直接完成的正常用法。
错误回传机制:todo_write 返回 error 时,引擎将其包装为 ToolResult{IsError: true, Output: errMsg} 注入上下文。LLM 看到工具调用失败的错误信息,被迫重新组织调用参数——这是 harness9"自愈"设计的体现:不终止循环,让 LLM 自行纠正。
工具层权限控制(filterReadOnlyTools)
Plan Mode 下,write_file 和 edit_file 从工具列表中完全移除,而不是通过 prompt 声明"不要创建文件"。
// agent_loop.go
var planModeWhitelist = map[string]bool{
"read_file": true,
"bash": true,
"use_skill": true,
"todo_write": true,
}
func filterReadOnlyTools(tools []schema.ToolDefinition) []schema.ToolDefinition {
var result []schema.ToolDefinition
for _, t := range tools {
if planModeWhitelist[t.Name] {
result = append(result, t)
}
}
return result
}
// runLoop 中
if planMode == planning.PlanModePlan {
availableTools = filterReadOnlyTools(availableTools)
}为什么在工具层而不是 prompt 层控制?
Prompt 是软约束。LLM 会忘记 prompt 中的限制(尤其在上下文压缩后),会被历史消息中出现的工具用法"诱导",会在某些情况下主动选择忽略约束。工具层是硬约束:从工具 schema 中移除一个工具,LLM 无论在哪个上下文状态下,都无法调用它——它在 API 层就不存在了。
todo_write 在白名单中,因为 Plan Mode 的核心目标就是让 LLM 通过 todo_write 输出结构化计划。
Plan Mode Prompt 注入
工具层的过滤无法表达"bash 只能用于只读命令"这类行为约束,因此 runLoop 在 Plan Mode 下对用户 prompt 追加前缀:
// agent_loop.go — runLoop
if planMode == planning.PlanModePlan {
userPrompt = "分析以下请求,用 todo_write 输出一份可直接执行的实现计划,然后用纯文字简述计划后停止。\n" +
"todo 项要求:每条对应一个具体的实现动作(例如:创建某文件、实现某函数、运行某命令),\n" +
"而非高层规划描述(禁止写\"需求澄清\"、\"方案设计\"之类无法直接执行的条目)。\n" +
"如需了解当前代码库,可使用 read_file 或 bash(只读命令:ls、cat、find、grep)。\n" +
"不要创建文件、执行 build/install 或做任何实际修改。\n\n" +
userPrompt
}注入原则:只说行为,不说权限。"你现在有权限 X"这样的 prompt 声明是冗余的——权限由工具层决定。Prompt 只引导 LLM"该做什么",不描述"能做什么"。
执行阶段 Prompt 设计
用户批准计划后,TUI 不是简单地发送"开始执行",而是发送一段精心设计的行为规范 prompt:
// tui_update.go
const execPrompt = "按照 todo 清单逐项执行。规则:\n" +
"1. 每开始一项前,用 todo_write 将其状态设为 in_progress\n" +
"2. 用工具完成该项的实际工作——创建文件、写代码、运行命令等;" +
"仅更新 todo_write 状态而不调用其他工具,不算完成该项\n" +
"3. 确认实际产出后,用 todo_write 将其状态设为 completed\n" +
"4. 不要输出进度摘要文字,立即处理下一项\n" +
"全部完成后,用一句话汇报整体结果。"规则 2 的设计意图是关键:明确告诉 LLM,"仅更新状态而不调用其他工具,不算完成"。这是对抗幻觉执行的 prompt 层约束,与工具层的批量完成检测形成双重防护。
续跑时使用更精简的 prompt,避免重复完整规则说明:
const execContinuePrompt = "继续处理 todo 清单中下一个 pending 或 in_progress 的任务项。" +
"先用 todo_write 标记为 in_progress,然后用工具完成实际工作(写文件、执行命令等)," +
"确认产出后标记为 completed,再处理下一项。" +
"不要只更新状态而不做实际操作,不要输出进度摘要。"自动执行循环与停滞检测
autoExecuting 标志开启后,每次 EventDone 事件触发以下决策逻辑:
// tui_update.go — EventDone handler
if m.autoExecuting && m.todoStore != nil {
items := m.todoStore.Read()
var pending, done int
for _, item := range items {
switch item.Status {
case planning.TodoPending, planning.TodoInProgress:
pending++
case planning.TodoCompleted:
done++
}
}
if pending > 0 {
if done > m.autoExecPrevDone {
m.autoExecStuck = 0 // 有进度,重置停滞计数
} else {
m.autoExecStuck++ // 无进度,停滞计数 +1
}
if m.autoExecStuck < 3 {
m.autoExecPrevDone = done
return m.dispatch(execContinuePrompt) // 续跑
}
// 连续 3 次无进度 → 停止
m.autoExecuting = false
m.lines = append(m.lines, dimStyle.Render(" ⚠ 执行停滞,请手动描述下一步"))
} else {
m.autoExecuting = false // 全部完成
}
}停滞检测的触发条件:连续 3 次 EventDone 后,已完成任务数(done)没有增加。这说明 Agent 在空转——它结束了推理但没有推进任何任务,可能是在输出纯文字、反复失败或陷入循环。
停滞检测用 done 计数(而非 pending 计数)判断进度,因为只有 completed 才代表真实的工作产出。pending → in_progress 的转变不应算作进度,因为它只是状态标记,不代表工作完成。
dispatch() 内置并发保护:
func (m tuiModel) dispatch(prompt string) (tuiModel, tea.Cmd) {
if m.running {
return m, nil // 已有推理在进行,静默忽略
}
// ...
}TUI 视觉集成
Plan Mode 色调
Plan Mode 激活时,TUI 从标准青色(#81)切换为琥珀黄色调,给用户明确的视觉信号:当前处于规划阶段,Agent 不会修改文件。
// tui.go — package-level 样式变量
planAccentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // 金黄
planStatusBarStyle = lipgloss.NewStyle().
Background(lipgloss.Color("94")).
Foreground(lipgloss.Color("220")).
Padding(0, 1)
planModeLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true)accentStyle() 和 activeStatusBarStyle() 方法按当前 planMode 返回对应样式,View 层统一调用,不散落 if planMode == 判断。
实时 Todo 快照
每次 todo_write 工具成功执行后,TUI 在工具完成行正下方追加当前任务列表快照:
// tui_update.go — EventToolResult
if m.currentTool == "todo_write" && !result.IsError && m.todoStore != nil {
m = m.updateTodoBlock()
}
// updateTodoBlock 简单追加,不原地替换
func (m tuiModel) updateTodoBlock() tuiModel {
todoLines := m.renderTodoLines(m.todoStore.Read())
if len(todoLines) == 0 {
return m
}
m.lines = append(m.lines, todoLines...)
return m
}快照使用状态图标可视化每个条目:
| 图标 | 状态 |
|---|---|
✔ | completed |
▶ | in_progress |
○ | pending |
⊘ | cancelled |
为什么追加而不是原地替换? 追加保留了完整的对话历史——用户可以向上滚动看到每次状态变化。原地替换只能看到最终状态,丢失了进度轨迹。代价是对话流会随 todo 更新增长,但 todo 列表通常不长,代价可接受。
审查对话框
Plan Mode 完成后,TUI 展示带圆角边框的选项对话框,暂停输入等待用户决策:
╭──────────────────────────────────────────────╮
│ Plan Mode 完成 — 选择下一步操作 │
│ │
│ [1] 批准并自动执行 │
│ [2] 批准并逐步确认编辑 │
│ [3] 继续修改计划(保持 Plan Mode) │
│ [4] 取消 │
╰──────────────────────────────────────────────╯选项 1 和 2 都将 autoExecuting 设为 true 并立即 dispatch execPrompt,当前行为相同,区别在于执行后的 planMode 设置:
- 选项 1:
planMode = PlanModeDefault,执行阶段具备完整工具权限 - 选项 2:
planMode = PlanModeAutoEdit(标注为"未实现"),工具层行为与 Default 相同,预留给未来的逐步确认模式扩展
选项 3:维持 planMode == PlanModePlan,允许用户继续向 Agent 提问或要求调整计划。
状态栏任务进度
// tui_view.go — renderStatusBar
items := m.todoStore.Read()
var completed int
for _, item := range items {
if item.Status == planning.TodoCompleted { completed++ }
}
// accent 颜色跟随当前 planMode:Default 为青色,Plan/AutoEdit 为琥珀黄
tasksPart = dimStyle.Render(" │ ") + accent.Render(fmt.Sprintf("%d/%d tasks", completed, len(items)))只统计 TodoCompleted 状态的条目作为"已完成",in_progress 不计入。状态栏显示类似 3/11 tasks,实时反映真实完成进度。颜色跟随当前 planMode 的 accentStyle()(Plan Mode 下为琥珀黄,默认模式下为青色)。
跨会话 Todo 持久化
TodoStore 的内容随 Session 持久化到 SQLite,进程重启或会话切换后可恢复未完成任务。
runLoop 在启动时恢复、在结束时保存(defer 保证即使 panic 也会执行):
// agent_loop.go — runLoop
// 启动时从 Session 恢复 TodoStore
if sess != nil && todoStore != nil {
if todos, err := sess.GetTodos(ctx); err == nil {
todoStore.Write(todos)
}
}
// 结束时保存(defer 在所有路径上执行)
defer func() {
if sess != nil && todoStore != nil {
if err := sess.SaveTodos(ctx, todoStore.Read()); err != nil {
log.Print(...)
}
}
}()跨 runLoop 调用的状态连续性:autoExecuting 模式下,每次续跑都会触发一次新的 runLoop。每次 runLoop 启动时都从 DB 恢复 TodoStore,确保续跑时的初始状态与上一次运行结束时的状态一致——这是 todo_write 防作弊校验能正常工作的前提:pending 的任务在上次运行结束后保存到 DB,下次运行加载回内存,校验逻辑可以准确识别任务的历史状态。
TUI /new 和 /resume 命令会触发 todoStore.Write(nil),清空内存中的任务列表,新会话从空列表开始。
上下文压缩中的 Todo 注入
当对话历史过长触发 SummarizationCompactor 时,旧消息会被 LLM 摘要压缩为单条摘要消息。如果未完成的 todo 被压入"旧消息"范围,LLM 可能在压缩后遗忘它们。
TodoStore 实现了 TodoInjector 接口,在每次生成摘要后将活跃任务(pending 和 in_progress)追加到摘要末尾:
// internal/planning/todo.go
func (s *TodoStore) FormatForInjection() string {
var lines []string
for _, item := range s.items {
if item.Status == TodoPending || item.Status == TodoInProgress {
prefix := "[ ]"
if item.Status == TodoInProgress { prefix = "[>]" }
lines = append(lines, fmt.Sprintf("%s %s", prefix, item.Content))
}
}
// ...
}// internal/memory/summarization.go — Compact()
summaryContent := summaryMarker + "\n" + summary
if c.TodoInjector != nil {
if todoText := c.TodoInjector.FormatForInjection(); todoText != "" {
summaryContent += "\n\n## Active Tasks\n" + todoText
}
}压缩后的摘要消息格式:
[Conversation Summary]
**Goal:** 构建一个 Go Web 应用...
**Progress:** 已创建目录结构,已初始化 go.mod...
**Next Steps:** 实现路由注册...
## Active Tasks
[ ] 实现 handler/user.go
[ ] 添加路由注册
[>] 配置数据库连接这确保了即使在长对话中触发多次压缩,Agent 也不会"忘记"还有哪些任务待完成。
数据流总结
用户 Shift+Tab
│
▼
tuiModel.planMode = PlanModePlan
eng.SetPlanMode(PlanModePlan) # 线程安全写入,runLoop 快照读取
用户输入任务 → dispatch(userPrompt)
│
▼
engine.runLoop
├─ 快照 planMode, todoStore
├─ GetTodos(ctx) → todoStore.Write(todos) # 从 DB 恢复任务状态
├─ 注入 Plan Mode 前缀 prompt
├─ filterReadOnlyTools() # 从工具列表移除 write_file/edit_file
└─ ReAct 循环
│ LLM 调用 todo_write
▼
TodoWriteTool.Execute()
├─ 读取 prevStatus(当前 store 快照)
├─ 计算 directCompletions(批量完成检测)
├─ directCompletions > 1 → error → LLM 重试
└─ store.Write(todos) → TUI EventToolResult → updateTodoBlock()
│ LLM 自然停止(无 ToolCall)
▼
defer SaveTodos(ctx, store.Read()) # 持久化到 SQLite
EventDone → planReviewing = true
│
▼
审查对话框(用户按 1)
│
▼
planMode = Default / eng.SetPlanMode(Default)
autoExecuting = true
dispatch(execPrompt)
│
▼
engine.runLoop(完整工具列表,执行 prompt)
│ Agent 执行工具,每项 in_progress → 工具调用 → completed
▼
EventDone
├─ pending > 0, done > prevDone → stuck=0, dispatch(execContinuePrompt)
├─ pending > 0, no progress → stuck++
│ stuck ≥ 3 → autoExecuting=false, 警告
└─ pending == 0 → autoExecuting=false, 完成