Skip to content

关于plan和build 模式的想法 #485

@phantom5099

Description

@phantom5099

一、背景与目标

1.1 现状问题

当前 NeoCode 只有一个"Build"模式:Agent 自主决定是分析代码、制定方案还是直接写文件。这种单模式存在以下问题:

  • 缺乏规划可见性:用户无法预先看到 Agent 的执行计划,只能在执行后才知道它做了什么
  • 上下文浪费:Plan 阶段的分析对话混入 Build 阶段的执行对话,加速上下文饱和
  • 偏离风险:Agent 可能在执行中偏离最初方案,用户难以控制
  • 审批粒度粗:只能在每次写操作时审批,无法对整体方案进行审批

1.2 目标

引入 Plan 模式(只读分析 + 生成计划)和 Build 模式(按批准的计划执行),实现:

  1. 用户可先审阅计划,再决定是否执行
  2. Plan 和 Build 的上下文可隔离,减少噪声
  3. Agent 执行时有明确的计划约束,减少偏离

二、核心设计原则

  1. 主流程不变TUI → Gateway → Runtime → Provider → Tools 主链路保持可用
  2. 权限硬约束:Plan 模式不是"提示词建议不写",而是"工具层面真的不能写"
  3. 显式 Handoff:Plan 产物必须显式传递,不能依赖上下文记忆
  4. 状态集中:模式状态、计划内容由 session 统一管理,不分散到 UI

三、方案总览

┌─────────────────────────────────────────────────────────────────────────────┐
│                              双模式架构总览                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   用户输入                                                                    │
│      │                                                                       │
│      ▼                                                                       │
│   ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                     │
│   │   /plan     │    │  计划审阅    │    │   /build    │                     │
│   │  命令/按钮   │───→│  批准/修改   │───→│  命令/按钮   │                     │
│   └─────────────┘    └─────────────┘    └─────────────┘                     │
│          │                                    │                              │
│          ▼                                    ▼                              │
│   ┌─────────────────────────────────────────────────────────────┐           │
│   │                      TUI / Gateway                           │           │
│   │  • 模式切换 UI                                              │           │
│   │  • 计划展示面板                                             │           │
│   │  • 审批交互(批准/修改/拒绝)                                │           │
│   └─────────────────────────────────────────────────────────────┘           │
│                              │                                               │
│                              ▼                                               │
│   ┌─────────────────────────────────────────────────────────────┐           │
│   │                        Runtime                               │           │
│   │  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐     │           │
│   │  │  Plan Run   │    │  模式切换    │    │ Build Run   │     │           │
│   │  │ • 只读工具   │───→│ • 保存计划   │───→│ • 全量工具   │     │           │
│   │  │ • 生成计划   │    │ • 切换提示词 │    │ • 执行计划   │     │           │
│   │  │ • 无verify  │    │             │    │ • 有verify   │     │           │
│   │  └─────────────┘    └─────────────┘    └─────────────┘     │           │
│   └─────────────────────────────────────────────────────────────┘           │
│                              │                                               │
│                              ▼                                               │
│   ┌─────────────────────────────────────────────────────────────┐           │
│   │                    Tools Manager                             │           │
│   │  ┌─────────────────────────────────────────────────────┐   │           │
│   │  │  Registry(全量工具,Bootstrap 注册,永不改变)        │   │           │
│   │  │  • filesystem_read/write/edit/grep/glob             │   │           │
│   │  │  • bash, webfetch, todo_write, spawn_subagent       │   │           │
│   │  │  • memo_*, mcp.*                                   │   │           │
│   │  └─────────────────────────────────────────────────────┘   │           │
│   │                         │                                    │           │
│   │                         ▼                                    │           │
│   │  ┌─────────────────────────────────────────────────────┐   │           │
│   │  │  DefaultManager(动态过滤,运行时切换)               │   │           │
│   │  │  • Plan 模式: ListAvailableSpecs() 过滤掉写工具      │   │           │
│   │  │  • Build 模式: ListAvailableSpecs() 返回全部工具     │   │           │
│   │  │  • Execute() 保持原有权限引擎检查                    │   │           │
│   │  └─────────────────────────────────────────────────────┘   │           │
│   └─────────────────────────────────────────────────────────────┘           │
│                              │                                               │
│                              ▼                                               │
│   ┌─────────────────────────────────────────────────────────────┐           │
│   │                      Session Store                           │           │
│   │  • AgentMode: "plan" | "build"                              │           │
│   │  • CurrentPlanID → PlanArtifact                             │           │
│   │  • Plans[]: 计划历史(可选)                                 │           │
│   │  • Messages: 对话历史(Plan 和 Build 共享)                   │           │
│   └─────────────────────────────────────────────────────────────┘           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

四、关键问题与可选方案

问题 1:工具注册与模式切换时工具可用性

问题描述

Bootstrap 时一次性注册所有工具到 Registry。Plan 模式需要隐藏写工具,Build 模式需要恢复。如何在运行时动态控制模型"看到"哪些工具?

可选方案

方案 A:Manager 层动态过滤

设计:Registry 保持全量注册不动,在 DefaultManager.ListAvailableSpecs() 中根据当前模式过滤。

type DefaultManager struct {
    // ... 现有字段
    modeMu sync.RWMutex
    mode   AgentMode  // "plan" | "build"
}

func (m *DefaultManager) SetMode(mode AgentMode) {
    m.modeMu.Lock()
    defer m.modeMu.Unlock()
    m.mode = mode
}

func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) {
    allSpecs, err := m.executor.ListAvailableSpecs(ctx, input)
    if err != nil { return nil, err }
    
    m.modeMu.RLock()
    mode := m.mode
    m.modeMu.RUnlock()
    
    if mode == AgentModePlan {
        return filterPlanModeSpecs(allSpecs), nil  // 过滤掉写工具
    }
    return allSpecs, nil  // Build 模式返回全部
}

Plan 模式白名单():

  • filesystem_read_file
  • filesystem_grep
  • filesystem_glob
  • webfetch
  • memo_recall
  • memo_list
  • filesystem_write_file
  • filesystem_edit
  • bash
  • todo_write
  • spawn_subagent
  • memo_remember
  • memo_remove

缺点

  • 如果绕过 Manager 直接调用 Registry,过滤失效(但现有代码不走这个路径)
  • 这里还有个问题,就是如果调用mcp工具,有些是write类型的,有些是read类型的,但是采取以上方案,现在的mcp是没有专门一个标记类别的字段?之前在遇到一些这个问题时,解决的方案好像都是临时打补丁区分类型,没有真正解决这个问题。
方案 B:Registry 层支持多视图

设计:Registry 内部维护多个工具子集,切换模式时切换视图。

type Registry struct {
    tools    map[string]Tool
    views    map[AgentMode]map[string]Tool  // 预构建的视图
}

func (r *Registry) BuildViews() {
    r.views[AgentModePlan] = filterTools(r.tools, planModeFilter)
    r.views[AgentModeBuild] = r.tools  // 全量
}

func (r *Registry) GetSpecsForMode(mode AgentMode) []providertypes.ToolSpec {
    view := r.views[mode]
    // ... 返回 view 的 specs
}

缺点

  • Registry 职责变重(存储 + 视图管理)
  • 需要改动 Registry 核心结构
  • 与现有 MCP 动态工具集成更复杂
方案 C:运行时重新注册工具

设计:切换模式时,销毁旧 Registry,创建新 Registry 重新注册对应工具。

func switchToPlanMode() {
    oldRegistry := toolManager.GetRegistry()
    newRegistry := tools.NewRegistry()
    newRegistry.Register(filesystem.NewRead(cfg.Workdir))
    newRegistry.Register(filesystem.NewGrep(cfg.Workdir))
    // ... 只注册读工具
    toolManager.SetRegistry(newRegistry)
}

缺点

  • 需要销毁和重建 Registry,有状态丢失风险
  • MCP 工具需要重新连接
  • 与现有依赖注入架构冲突(Registry 通过构造函数注入)
  • 性能差,切换模式有延迟

问题 2:Plan 模式的主流程改动

问题描述

当前主流程是 plan → execute → verify 循环。Plan 模式是否还需要 execute?如果不需要,主流程怎么改?

分析

Plan 模式仍然需要 execute,但 execute 的内容不同:

阶段 Build 模式 Plan 模式
Plan(模型思考) 模型自主决定读/写/执行 模型只能决定读
Execute(工具执行) 执行写文件、bash、edit 执行 read_file、grep、glob
Verify(结果验证) acceptance 检查、测试 不需要
终止条件 任务完成/失败/用户停止 计划生成完成

可选方案

方案 A:统一 ReAct 循环,模式分支在终止条件

设计Run() 方法的整体循环结构不变,只在两个地方做模式分支:

  1. 工具暴露prepareTurnBudgetSnapshot()ListAvailableSpecs() 已按模式过滤
  2. 终止条件:无 tool calls 时,Plan 模式直接返回计划,Build 模式进入 verify
func (s *Service) Run(ctx context.Context, input UserInput) error {
    // ... 原有初始化
    mode := resolveAgentMode(input, state.session)
    
    for turn := 0; ; turn++ {
        // ... 原有预算、provider 调用(完全不变)
        
        if !hasToolCalls {
            // ========== 模式分支点 1:终止条件 ==========
            if mode == AgentModePlan {
                // Plan 模式:提取计划,保存,结束
                planContent := extractPlanFromResponse(turnOutput.assistant)
                if planContent != "" {
                    savePlan(state.session, planContent)
                    emitEvent(EventPlanCompleted, planContent)
                    return nil  // Plan 模式结束,不 verify
                }
                // 计划不完整,继续循环
                continue
            }
            
            // Build 模式:原有 verify/acceptance 逻辑(完全不变)
            acceptanceDecision, err := s.beforeAcceptFinal(...)
            // ...
        }
        
        // ========== Execute(两种模式都需要)==========
        // 但 Plan 模式下,模型只能调用读工具(已被过滤)
        summary, err := s.executeAssistantToolCalls(ctx, &state, snapshot, turnOutput.assistant)
        // ... 原有 progress 评估(完全不变)
    }
}

优点

  • 主流程改动最小,只有 2 个分支点
  • Plan 模式仍然利用现有的 execute 并发、错误处理、事件发射
  • 代码复用最高

缺点

  • Plan 模式会走一遍完整的 tool execution 流程(虽然只执行读工具)
  • 需要确保 Plan 模式的 progress 评估不会误判"无进展"
方案 B:Plan 模式独立循环(不经过 execute)

设计:Plan 模式完全绕过 executeAssistantToolCalls(),自己实现一个简化的读工具循环。

func (s *Service) RunPlanMode(ctx context.Context, input UserInput) error {
    for turn := 0; ; turn++ {
        // 调用 provider(只传入读工具 specs)
        response := s.callProvider(ctx, readOnlyTools)
        
        if !hasToolCalls {
            // 检查是否生成了计划
            if isPlanComplete(response) {
                savePlan(response)
                return nil
            }
            continue
        }
        
        // 简化的读工具执行(不经过完整的 executeAssistantToolCalls)
        for _, call := range response.ToolCalls {
            result := s.executeReadTool(ctx, call)  // 直接执行,不经过权限审批
            appendToMessages(result)
        }
    }
}

优点

  • Plan 模式代码完全独立,不会意外执行写工具
  • 可以针对 Plan 模式优化(如不需要权限审批、不需要并发控制)

缺点

  • 代码重复:两个独立的 ReAct 循环
  • 维护成本高:改一个逻辑要改两处
  • 与现有事件系统、progress 系统需要重新集成
方案 C:Plan 模式作为特殊 Tool

设计:不区分 Plan/Build 模式,而是提供一个 generate_plan 工具。Agent 在 Build 模式中可以调用这个工具来生成计划。

// Agent 调用 generate_plan 工具
// 工具内部:只读分析 → 生成计划 → 返回计划内容
// 用户审批后,计划作为消息注入,Agent 继续执行

优点

  • 完全不需要模式概念,架构最简单
  • 计划生成是 Agent 自主决定的行为

缺点

  • 无法强制"先计划后执行"的工作流
  • 用户无法预先知道 Agent 会生成计划
  • 计划审批流程难以集成(工具调用是自动的,没有用户暂停点)

问题 3:会话状态改动

问题描述

模式状态、计划内容放在哪里?runState(单次运行临时状态)还是 Session(持久化状态)?

分析

状态 生命周期 存放位置
AgentMode 跨 Run 持久化 Session
PlanContent 跨 Run 持久化 Session
PlanApproved 跨 Run 持久化 Session
CurrentPlanID 跨 Run 持久化 Session

原因:用户可能在 Plan 模式生成计划后,关闭 TUI,明天再打开进入 Build 模式。这些状态必须持久化。

可选方案

方案 A:扩展 Session 结构

设计:在 SessionSessionHead 中增加模式相关字段。

// internal/session/store.go

type AgentMode string

const (
    AgentModePlan  AgentMode = "plan"
    AgentModeBuild AgentMode = "build"
)

type PlanStatus string

const (
    PlanStatusDraft     PlanStatus = "draft"
    PlanStatusApproved  PlanStatus = "approved"
    PlanStatusRejected  PlanStatus = "rejected"
    PlanStatusExecuting PlanStatus = "executing"
    PlanStatusCompleted PlanStatus = "completed"
)

type PlanArtifact struct {
    ID          string
    Version     int
    Title       string
    Content     string        // 完整计划(Markdown)
    Summary     string        // 精简摘要(用于上下文注入)
    Status      PlanStatus
    CreatedAt   time.Time
    ApprovedAt  *time.Time
    ExecutedAt  *time.Time
    CompletedAt *time.Time
}

type Session struct {
    ID               string
    Title            string
    Provider         string
    Model            string
    CreatedAt        time.Time
    UpdatedAt        time.Time
    Workdir          string
    TaskState        TaskState
    ActivatedSkills  []SkillActivation
    TodoVersion      int
    Todos            []TodoItem
    Messages         []providertypes.Message
    TokenInputTotal  int
    TokenOutputTotal int
    HasUnknownUsage  bool
    
    // 新增字段
    AgentMode    AgentMode      // 当前模式
    CurrentPlanID string        // 当前活跃计划 ID
    Plans        []PlanArtifact // 计划历史(可选,MVP 可只存 CurrentPlan)
}

type SessionHead struct {
    // ... 原有字段
    AgentMode     AgentMode
    CurrentPlanID string
    Plans         []PlanArtifact
}

数据库迁移:SQLite schema 从 v2 升级到 v3,新增 agent_modecurrent_plan_idplans 字段。

优点

  • 状态集中,符合 NeoCode "状态由 runtime/session 统一管理"的原则
  • 持久化,支持跨会话的模式切换
  • 计划历史可追溯

缺点

  • 需要数据库 schema 迁移
  • Session 结构变重
方案 B:独立 Plan Store

设计:计划不放在 Session 中,而是独立的存储。

type PlanStore interface {
    SavePlan(ctx context.Context, sessionID string, plan PlanArtifact) error
    GetPlan(ctx context.Context, planID string) (PlanArtifact, error)
    ListSessionPlans(ctx context.Context, sessionID string) ([]PlanArtifact, error)
}

Session 只存 CurrentPlanID,计划内容在独立 Store 中。

优点

  • Session 结构不变轻量
  • 计划可以独立管理(版本、搜索、导出)

缺点

  • 引入新的存储层,复杂度增加
  • Plan 和 Session 的一致性需要维护
  • 与现有 Session 事务机制需要集成
方案 C:计划作为特殊消息

设计:不改动 Session 结构,计划内容作为一条特殊消息存入 Session.Messages

// Plan 完成时,插入一条特殊消息
planMessage := providertypes.Message{
    Role: providertypes.RoleSystem,
    Parts: []providertypes.ContentPart{
        providertypes.NewTextPart("[PLAN]\n" + planContent + "\n[/PLAN]"),
    },
}

// Build 模式启动时,扫描 Messages 找到最新的 [PLAN] 标记

优点

  • 零数据库改动
  • 计划自然融入对话历史

缺点

  • 计划被上下文压缩影响(compact 时可能被摘要或删除)
  • 难以支持计划版本、状态管理
  • 解析计划需要扫描全部消息,效率低

问题 4:计划文件的必要性与实现

问题描述

计划内容必须持久化吗?其他 Coding Agent 怎么做?计划如何在多轮对话后保持可识别?

调研结论

工具 计划形式 持久化 handoff 方式
Claude Code Markdown 文件(.claude/plans/ ✅ 文件系统 文件路径引用
Cline 内联对话中的结构化计划 ❌ 仅上下文 上下文记忆
Roo Code Todo 列表 + 对话 ⚠️ 半持久化 Todo 状态
VS Code Copilot 内联对话 ❌ 仅上下文 上下文记忆
OpenCode (apiad) 结构化文档(.playground/ ✅ 文件系统 文件路径引用
Codex (OpenAI) 内联对话 ❌ 仅上下文 上下文记忆

核心问题:上下文饱和

如果计划只在上下文中:

  1. Plan 阶段的分析对话占用大量 token
  2. 进入 Build 后,这些对话成为噪声
  3. 模型可能遗忘计划细节,执行偏离
  4. 用户无法编辑计划

可选方案

方案 A:Session 内 Plan Artifact + System Prompt 注入

设计

  1. 计划内容保存在 Session.Plans[] 中(持久化)
  2. Build 模式启动时,将计划内容直接注入 system prompt
  3. 模型始终通过 system prompt "看到"计划,不受上下文饱和影响
// Build 模式启动时,构建请求
func buildBuildModeRequest(state *runState, plan PlanArtifact) providertypes.GenerateRequest {
    systemPrompt := buildBaseSystemPrompt(state)
    
    // 注入计划到 system prompt(最高优先级)
    systemPrompt += fmt.Sprintf(`

## Approved Plan(MUST FOLLOW)

%s

## Execution Rules
1. Execute the plan step by step
2. Do not deviate without user approval
3. Report progress after each step
`, plan.Content)
    
    return providertypes.GenerateRequest{
        Model:        state.session.Model,
        SystemPrompt: systemPrompt,
        Messages:     state.session.Messages,
        Tools:        allToolSpecs,  // Build 模式:全部工具
    }
}

Plan 在 Session 中的存储

type Session struct {
    // ...
    AgentMode     AgentMode
    CurrentPlanID string
    Plans         []PlanArtifact
}

type PlanArtifact struct {
    ID         string
    Content    string  // 完整计划
    Summary    string  // 精简摘要(用于快速展示)
    Status     PlanStatus
    CreatedAt  time.Time
    ApprovedAt *time.Time
}

优点

  • 计划通过 system prompt 注入,最高优先级,模型不会遗忘
  • 持久化在 Session 中,跨 Run 可用
  • 支持计划版本历史
  • 不依赖文件系统,与现有 SQLite 存储一致

缺点

  • 长计划会占用 system prompt 的 token(但 plan 通常比分析对话短)
方案 B:文件系统 Plan + 路径引用

设计:计划保存为文件(如 .neocode/plans/{session_id}/{plan_id}.md),Build 时通过工具让模型读取。

// Plan 完成时保存到文件
func savePlanToFile(sessionID string, plan PlanArtifact) (string, error) {
    path := filepath.Join(workdir, ".neocode", "plans", sessionID, plan.ID+".md")
    // 写入文件
    return path, nil
}

// Build 模式 system prompt 中注入文件路径
systemPrompt += `
## Approved Plan
Read the plan from: .neocode/plans/{session_id}/{plan_id}.md
You MUST follow this plan.
`

优点

  • 用户可以直接编辑文件
  • 计划可以版本控制(git)
  • 不占用上下文 token

缺点

  • 模型需要额外调用 read_file 读取计划,增加一轮工具调用
  • 文件可能被用户误删/修改
  • 需要管理文件生命周期(清理过期计划)
  • 与现有纯 SQLite 存储架构不一致
方案 C:上下文内联 + 定期提醒

设计:计划不单独存储,而是作为对话的一部分。每隔几轮,插入一条提醒消息。

// 每 5 轮 Build 执行后,插入计划提醒
if turn%5 == 0 {
    reminder := providertypes.Message{
        Role: providertypes.RoleSystem,
        Parts: []providertypes.ContentPart{
            providertypes.NewTextPart("Reminder: Current plan is to..."),
        },
    }
    appendToMessages(reminder)
}

优点

  • 最简单,无额外存储

缺点

  • 计划内容易丢失(compact 时)
  • 需要频繁提醒,浪费 token
  • 模型仍可能偏离

五、完整数据流

5.1 Plan 模式数据流

用户: "/plan 实现用户登录功能"
    │
    ▼
TUI: 发送 PrepareInput{Text: "实现用户登录功能", SessionID: "sess_123"}
    │
    ▼
Gateway: → Runtime.Submit()
    │
    ▼
Runtime:
  1. 检查 Session.AgentMode,当前为 ""(默认 Build)
  2. 用户输入以 "/plan" 开头,切换模式
  3. Session.AgentMode = "plan"
  4. toolManager.SetMode(AgentModePlan)  // 过滤写工具
  5. 调用 Run()
    │
    ▼
Run() ReAct 循环:
  Turn 0:
    - prepareTurnBudgetSnapshot(): ListAvailableSpecs() 返回只读工具
    - callProvider(): 模型收到只读工具 specs
    - 模型调用: read_file("auth.go"), grep("login"), glob("*.go")
    - executeAssistantToolCalls(): 执行读工具
    - 结果回灌 Messages
  Turn 1-3:
    - 模型继续分析代码结构
    - 调用更多读工具
  Turn 4:
    - 模型不再调用工具,返回计划文本
    - extractPlanFromResponse(): 提取计划内容
    - savePlan(): 保存到 Session.CurrentPlan
    - emit EventPlanCompleted
    - return nil  // Plan 模式结束
    │
    ▼
Runtime: Session 持久化到 SQLite
    │
    ▼
TUI: 展示计划内容,等待用户审批

5.2 模式切换数据流

用户: 点击"批准计划"按钮
    │
    ▼
TUI: 发送 ModeSwitchInput{SessionID: "sess_123", Action: "approve_plan"}
    │
    ▼
Gateway: → Runtime.ApprovePlan()
    │
    ▼
Runtime:
  1. 加载 Session
  2. Session.CurrentPlan.Status = "approved"
  3. Session.CurrentPlan.ApprovedAt = now()
  4. Session.AgentMode = "build"  // 切换模式
  5. toolManager.SetMode(AgentModeBuild)  // 恢复全部工具
  6. 可选:自动触发 Build Run
    │
    ▼
Session 持久化

5.3 Build 模式数据流

用户: "/build" 或自动触发
    │
    ▼
Runtime.Run():
  1. 检查 Session.AgentMode = "build"
  2. 检查 Session.CurrentPlan.Status = "approved"
  3. prepareTurnBudgetSnapshot():
     - ListAvailableSpecs() 返回全部工具
     - systemPrompt 注入计划内容
  4. callProvider():
     - 模型收到全部工具 + 计划 system prompt
  5. ReAct 循环执行计划...
  6. 最终 verify/acceptance
    │
    ▼
Session.CurrentPlan.Status = "completed"
Session.AgentMode = ""(重置为默认)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions