Skip to content

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 枚举

go
// 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 切换的影响:

go
// agent_loop.go — runLoop 入口
e.mu.RLock()
planMode := e.planMode   // 快照,循环中不变
e.mu.RUnlock()

TodoStore

TodoStore 是 Planning 模块的核心数据结构——一个线程安全的内存任务列表,采用**全量替换(atomic replace)**语义。

设计选择:全量替换 vs 增量更新

大多数任务管理系统使用增量 API(add / update / delete)。harness9 选择全量替换,原因如下:

  1. LLM 的自然输出形式是列表:每次 LLM 调用 todo_write 时,它直接输出完整的当前清单,而不是"第 3 项的状态从 pending 改为 in_progress"这样的增量指令。全量替换与这种输出形式完全匹配。
  2. 避免状态不一致:增量 API 要求 LLM 维护对旧状态的准确认知,一旦出错(如 ID 拼写错误),状态就会发散。全量替换让每次写入都是确定性的快照。
  3. 实现简单Write 只需 copy(s.items, items),没有合并逻辑,没有冲突处理。

实现细节

go
// 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 字符串常量:

go
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_filebash 等工具平级。

工具定义

go
// 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 个视为批量作弊,拒绝写入。

go
// 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_fileedit_file 从工具列表中完全移除,而不是通过 prompt 声明"不要创建文件"。

go
// 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 追加前缀:

go
// 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:

go
// 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,避免重复完整规则说明:

go
const execContinuePrompt = "继续处理 todo 清单中下一个 pending 或 in_progress 的任务项。" +
    "先用 todo_write 标记为 in_progress,然后用工具完成实际工作(写文件、执行命令等)," +
    "确认产出后标记为 completed,再处理下一项。" +
    "不要只更新状态而不做实际操作,不要输出进度摘要。"

自动执行循环与停滞检测

autoExecuting 标志开启后,每次 EventDone 事件触发以下决策逻辑:

go
// 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() 内置并发保护:

go
func (m tuiModel) dispatch(prompt string) (tuiModel, tea.Cmd) {
    if m.running {
        return m, nil  // 已有推理在进行,静默忽略
    }
    // ...
}

TUI 视觉集成

Plan Mode 色调

Plan Mode 激活时,TUI 从标准青色(#81)切换为琥珀黄色调,给用户明确的视觉信号:当前处于规划阶段,Agent 不会修改文件。

go
// 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 在工具完成行正下方追加当前任务列表快照:

go
// 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 提问或要求调整计划。

状态栏任务进度

go
// 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,实时反映真实完成进度。颜色跟随当前 planModeaccentStyle()(Plan Mode 下为琥珀黄,默认模式下为青色)。


跨会话 Todo 持久化

TodoStore 的内容随 Session 持久化到 SQLite,进程重启或会话切换后可恢复未完成任务。

runLoop 在启动时恢复、在结束时保存(defer 保证即使 panic 也会执行):

go
// 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 接口,在每次生成摘要后将活跃任务(pendingin_progress)追加到摘要末尾:

go
// 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))
        }
    }
    // ...
}
go
// 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, 完成

Released under the MIT License.