// plan.go - Plan Mode 核心状态机 // // 定位:模块 17 UltraPlan.实现 EnterPlanMode / ExitPlanMode 两个内置工具, // 管理"计划模式"生命周期:进入 → 探索/写计划 → 提交审批 → 退出. // // 核心设计决策: // // 1. 文件读取,非参数传递(安全防注入) // ExitPlanMode 从 PlanStore 读计划内容,而非接受 plan 参数. // 早期实现 ExitPlanModeV2Tool 同样从磁盘读-- // 如果允许参数传入,模型可以注入任意内容绕过用户审查. // // 2. ApprovalPolicy 接口(消费层决策,引擎不假设 UI) // CLI 实现终端交互审批,SDK/API 注入函数回调,测试用 NoopApprovalPolicy. // 引擎本身不知道 "如何显示计划",只知道 "计划需要批准". // // 3. prePlanMode 保存与恢复 // 进入 plan 模式前记录当前权限模式,退出时还原. // 防止从 bypass/accept_edits 进入计划模式后无法正确恢复. // // 4. PlanStep 依赖图(P1) // Deps []string 引用其他 step 的 ID,消费方(CLI/SDK)决定调度顺序. // 引擎只暴露结构,不做调度--符合"叠加而非替换"原则. // // 对应早期方案: // - 早期方案 EnterPlanMode 工具 // - 早期方案 ExitPlanMode 工具 // - 早期方案 plans 实现 // // 升华改进(ELEVATED): 早期方案 EnterPlanMode 依赖全局单例(AppState, planSlugCache) // 和 React 状态系统(context.setAppState).Go 实现用 PlanModeManager 封装状态, // 通过构造函数注入,测试友好,无全局副作用. // 替代方案:<直接在 Engine struct 加 planMode 字段> - // 否决原因:计划模式逻辑和引擎核心混在一起,不易单独测试,也不易未来拆出. package engine import ( "context" "encoding/json" "fmt" "sync" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // ───────────────────────────────────────────── // PlanStep - 计划步骤(P1) // ───────────────────────────────────────────── // Complexity 表示步骤的预估复杂度. type Complexity string const ( ComplexityLow Complexity = "low" ComplexityMedium Complexity = "medium" ComplexityHigh Complexity = "high" ) // PlanStep 是计划中的一个可执行步骤. // // 升华改进(ELEVATED): 早期实现 计划是纯 Markdown 文件,没有结构化步骤. // 我们增加 PlanStep 允许消费方解析依赖关系做并行调度. // 引擎只暴露结构--CLI/SDK/Coordinator 决定调度策略,不同场景可不同. // 替代方案:<计划只是 string,步骤全靠模型在 Markdown 里描述> - // 否决原因:消费方无法机器解析并行性,只能串行执行,浪费多 Agent 机会. type PlanStep struct { // ID 步骤唯一标识符(模型生成,通常是顺序编号如 "step-1"). ID string // Description 步骤的人类可读描述. Description string // Tools 该步骤预期用到的工具列表(提示性,不做强制限制). Tools []string // Complexity 预估复杂度(low/medium/high). Complexity Complexity // Deps 依赖的步骤 ID 列表.空表示无依赖(可并行执行). // 精妙之处(CLEVER): Deps 是拓扑排序的输入,而不是"顺序"-- // 消费方可以 Kahn 算法找出可并行的步骤层. // 如果用 "after: step-2" 的字符串指令,消费方要解析 Markdown,容易错. Deps []string } // ───────────────────────────────────────────── // ApprovalPolicy - 审批策略接口 // ───────────────────────────────────────────── // PlanApprovalEvent 是计划审批事件,由 ExitPlanMode 工具触发. // // 精妙之处(CLEVER): 审批事件携带 Approve/Reject 函数而不是返回 bool-- // 这让 CLI 实现可以在异步渲染审批 UI 后回调,而不需要阻塞工具执行线程. // SDK 实现可以把事件发送给外部系统(Slack 审批机器人,审批工作流)然后回调. type PlanApprovalEvent struct { // SessionID 标识是哪个会话的计划. SessionID string // Plan 计划的完整文本内容(从 PlanStore 读取). Plan string // FilePath 计划文件的逻辑路径(用于展示给用户). FilePath string // Steps 可选的结构化步骤列表(如果计划包含 YAML/JSON 步骤块则解析). Steps []PlanStep // Approve 批准计划(允许执行).editedPlan 非空时用编辑后的版本覆盖原计划. // 必须调用 Approve 或 Reject 之一,否则 ExitPlanMode 会一直等待. Approve func(editedPlan string) error // Reject 拒绝计划(保持 plan 模式,让模型修改计划). Reject func(reason string) error } // EventType lets PlanApprovalEvent satisfy engine.Event so it can flow // through the Run channel and be serialized by bridge.EventSerializer. // Consumers subscribing to Run events can receive the approval event and // invoke Approve / Reject asynchronously (push mode). // // EventType 让 PlanApprovalEvent 实现 engine.Event, 能走 Run channel 并 // 被 bridge.EventSerializer 序列化. 订阅 Run 事件的消费者可收到审批事件 // 并异步调 Approve / Reject (push 模式). func (e *PlanApprovalEvent) EventType() string { return "plan_approval" } // ApprovalPolicy 抽象计划审批机制. // // 升华改进(ELEVATED): 早期实现 ExitPlanMode 直接渲染 React 组件(PermissionRequest) // 并等待用户在 TUI 中点击.这完全无法用于 SDK 嵌入或 HTTP API. // 我们用 ApprovalPolicy 接口解耦:CLI 实现终端渲染,SDK 注入函数回调, // SaaS 实现 WebSocket/webhook 审批,测试用 NoopApprovalPolicy 自动批准. // 替代方案:<直接在 ExitPlanMode 工具内渲染 UI> - // 否决原因:工具层依赖 UI 框架,无法在 HTTP API 服务中复用. // // Shape: synchronous callback. Engine passes a PlanApprovalEvent to the // consumer and blocks until the consumer invokes Approve / Reject in the // event struct or returns from RequestApproval. // // 形态: 同步回调. 引擎把 PlanApprovalEvent 交给消费者实现, 阻塞直到消费者 // 调用 event 里的 Approve / Reject 或 RequestApproval 返回. type ApprovalPolicy interface { // RequestApproval 请求用户审批计划.阻塞直到用户批准或拒绝. // 返回: // - approved: 是否批准 // - editedPlan: 用户编辑后的计划文本(空字符串 = 未编辑,使用早期方案) // - err: 系统错误(不是用户拒绝) RequestApproval(ctx context.Context, event PlanApprovalEvent) (approved bool, editedPlan string, err error) } // NoopApprovalPolicy 自动批准所有计划,用于测试和 bypass 场景. type NoopApprovalPolicy struct{} func (NoopApprovalPolicy) RequestApproval(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return true, "", nil } // FuncApprovalPolicy 用函数实现审批策略(SDK 嵌入最常用). // // 升华改进(ELEVATED): 函数式审批策略让 SDK 用户不需要实现接口, // 只需要传一个 func 就能定制审批逻辑. // 对比:需要实现接口的方式在 Go 里更正式但更繁琐,不适合快速集成. type FuncApprovalPolicy struct { Fn func(ctx context.Context, event PlanApprovalEvent) (approved bool, editedPlan string, err error) } func (p FuncApprovalPolicy) RequestApproval(ctx context.Context, event PlanApprovalEvent) (bool, string, error) { return p.Fn(ctx, event) } // ───────────────────────────────────────────── // PlanModeManager - 状态机核心 // ───────────────────────────────────────────── // PlanModeManager 管理 plan 模式的进入/退出生命周期. // 它是 EnterPlanModeTool 和 ExitPlanModeTool 的共享状态. // // 精妙之处(CLEVER): Manager 通过构造函数注入,没有全局变量-- // 同一个进程中可以有多个 Engine 实例(多租户/测试),各自独立管理计划状态. // 早期实现 用全局 AppState 单例,在多实例场景下会互相污染. type PlanModeManager struct { mu sync.Mutex active bool // 当前是否处于 plan 模式 prePlanMode permission.Mode // 进入 plan 前的权限模式,退出时还原 perms permission.Checker store PlanStore approval ApprovalPolicy sessionID string // 用于路由到正确的计划文件(session-scoped) // progress 是审批通过后的步骤执行进度追踪器(可选). // 通过 AttachProgress 绑定,nil 时表示不追踪进度. // // 升华改进(ELEVATED): progress 生命周期独立于 active-- // active=false(计划已批准,执行中)时 progress 仍然有效. // 替代方案: - // 否决原因:执行阶段正是进度最有意义的时候,此时清空等于丢失数据. progress *PlanProgress } // NewPlanModeManager 创建计划模式管理器. // // store 控制计划文件存储位置(FilePlanStore for CLI,MemoryPlanStore for SDK/测试). // approval 控制审批机制(NoopApprovalPolicy for 测试,CLI 实现用 FuncApprovalPolicy). // perms 是权限引擎引用,进入/退出时更新模式. func NewPlanModeManager(store PlanStore, approval ApprovalPolicy, perms permission.Checker) *PlanModeManager { if store == nil { store = NewMemoryPlanStore("") } if approval == nil { approval = NoopApprovalPolicy{} } return &PlanModeManager{ store: store, approval: approval, perms: perms, } } // SetSessionID 设置当前会话 ID(Engine.Run 开始时调用). // 会话 ID 用于路由到正确的计划文件. func (m *PlanModeManager) SetSessionID(id string) { m.mu.Lock() defer m.mu.Unlock() m.sessionID = id } // AttachProgress 绑定步骤进度追踪器. // // 通常在 Exit 返回批准的计划(含 Steps)后由消费方构建 PlanProgress 并调用此方法绑定, // 之后消费方通过 PlanProgress 追踪每个步骤的执行状态. // // 精妙之处(CLEVER): 消费方主导进度追踪的创建-- // 消费方知道"要不要追踪进度"(简单 SDK 场景不需要),知道"用什么 observer", // 知道"步骤列表"(来自 PlanApprovalEvent.Steps 或自行解析计划文本). // 引擎只提供存储挂载点,不强制创建. // 替代方案: - 否决原因:自动创建需要 Exit 知道 // Observer 配置,把可观测性配置耦合到审批流程,关注点混杂. func (m *PlanModeManager) AttachProgress(p *PlanProgress) { m.mu.Lock() defer m.mu.Unlock() m.progress = p } // Progress 返回当前绑定的进度追踪器(可能为 nil). // 消费方用此方法获取追踪器后调用 StartStep / FinishStep 等方法. func (m *PlanModeManager) Progress() *PlanProgress { m.mu.Lock() defer m.mu.Unlock() return m.progress } // IsActive 返回当前是否处于 plan 模式. func (m *PlanModeManager) IsActive() bool { m.mu.Lock() defer m.mu.Unlock() return m.active } // Enter 进入 plan 模式.保存当前权限模式,切换到 ModePlan. // 如果已经在 plan 模式,返回错误(防止嵌套进入). func (m *PlanModeManager) Enter() error { m.mu.Lock() defer m.mu.Unlock() if m.active { return fmt.Errorf("plan_mode: already active") } // 记录当前模式,退出时恢复 if m.perms != nil { m.prePlanMode = m.perms.Mode() m.perms.SetMode(permission.ModePlan) } m.active = true return nil } // approvalResult carries a single approval decision from any resolver path // (push event.Approve / event.Reject, or pull policy.RequestApproval return). // // approvalResult 携带单条审批决策, 来源可能是任一 resolver 路径 (push 的 // event.Approve / event.Reject, 或 pull 的 policy.RequestApproval 返回值). type approvalResult struct { approved bool edited string err error } // Exit 退出 plan 模式, 读取计划, 触发审批 (push + pull 双路), 恢复权限模式. // // 审批决策有两种来源: // // pull: policy.RequestApproval 同步返回 (approved/edited/err) // push: event.Approve(edited) / event.Reject(reason) 被订阅者异步调用 // // Exit 用 resolveCh (容量 1) 收第一条决策, first-to-resolve 胜; 另一路的 // 后续写入被 tryResolve 的 select default 丢弃. 两种模式消费者选一个实现 // 即可 — pull 向后兼容 (policy 实现者照旧填返回值); push 激活 (订阅 Run // channel 的 TUI/SDK 收到 PlanApprovalEvent 后异步调 Approve/Reject). // // push 通道用上层 ctx EventEmitter (event_emitter.go) 把 event 送到父 Run // channel; emitter 由 engine 主工具派发时注入, 外部代码无法伪造 (私有 key). // // steps 从工具 input 解析的结构化步骤列表 (optional); 填入 event.Steps 供 // 消费方做并行调度. nil 表示模型没给结构化步骤 (仅 markdown 计划). // // goroutine 生命周期: 用派生 ctx + defer cancel 确保 Exit 返回时 policy 的 // goroutine 收到取消信号, policy 实现者有责任听 ctx.Done 退出 (Go ctx 标准 // 协议). 若 policy 不听, 仅影响其自身 goroutine, 不阻塞 Exit. // // 返回: // - plan: 批准后的计划文本 // - err: ErrPlanRejected 表示用户拒绝; ctx.Err 表示超时/取消; 其他为系统错误 // // Exit exits plan mode, reads the plan, triggers approval via push + pull // dual paths, and restores the permission mode. // // Approval decisions come from two sources: // // pull: policy.RequestApproval's synchronous return (approved/edited/err) // push: event.Approve(edited) / event.Reject(reason) invoked async by subscribers // // Exit uses a capacity-1 resolveCh to receive the first decision; // first-to-resolve wins, and the later writer is dropped by tryResolve's // select default. Consumers pick one mode — pull stays backward compatible // (policy implementors still return values); push is now active (TUI/SDK // subscribers receive PlanApprovalEvent on Run channel and call // Approve/Reject asynchronously). // // The push channel uses the ctx EventEmitter (event_emitter.go) to send the // event into the parent Run channel. The emitter is injected by the engine // main dispatch path and cannot be spoofed by external code (private key). // // steps are the structured step list parsed from the tool input (optional); // filled into event.Steps for downstream schedulers. nil means the model // did not supply structured steps (markdown-only plan). // // Goroutine lifecycle: a derived ctx + defer cancel ensures the policy's // goroutine gets a cancel signal when Exit returns. Policy implementors // are responsible for honoring ctx.Done (standard Go ctx protocol); if // they don't, only their own goroutine stalls, not Exit. func (m *PlanModeManager) Exit(ctx context.Context, steps []PlanStep) (plan string, err error) { m.mu.Lock() sessionID := m.sessionID store := m.store approval := m.approval perms := m.perms prePlanMode := m.prePlanMode m.mu.Unlock() if !m.IsActive() { return "", fmt.Errorf("plan_mode: not active") } // 从 PlanStore 读取计划 (不从参数读 — 防注入) planContent, err := store.ReadPlan(sessionID) if err != nil { return "", fmt.Errorf("plan_mode: read plan: %w", err) } filePath := store.PlanPath(sessionID) resolveCh := make(chan approvalResult, 1) tryResolve := func(r approvalResult) { select { case resolveCh <- r: default: // first-to-resolve wins; drop late arrivals // first-to-resolve 胜, 后到的丢弃 } } event := PlanApprovalEvent{ SessionID: sessionID, Plan: planContent, FilePath: filePath, Steps: steps, Approve: func(editedPlan string) error { tryResolve(approvalResult{approved: true, edited: editedPlan}) return nil }, Reject: func(_ string) error { tryResolve(approvalResult{approved: false}) return nil }, } // push path: emit event to parent Run channel via ctx emitter. External // subscribers receive it and may call Approve/Reject asynchronously. // push 路径: 经 ctx emitter 把 event 推到父 Run channel. 外部订阅者 // 收到后可异步调 Approve/Reject. if emit := EventEmitterFromContext(ctx); emit != nil { emit(&event) } // pull path: run policy.RequestApproval in a goroutine; its return is // also fed into resolveCh, racing against push decisions. Derived ctx // lets Exit signal policy to cancel via defer. // pull 路径: goroutine 跑 policy.RequestApproval, 返回值也喂 resolveCh, // 和 push 决策竞速. 派生 ctx 让 Exit 经 defer 给 policy 发取消信号. policyCtx, cancelPolicy := context.WithCancel(ctx) defer cancelPolicy() go func() { approved, edited, policyErr := approval.RequestApproval(policyCtx, event) if policyErr != nil { tryResolve(approvalResult{err: policyErr}) return } tryResolve(approvalResult{approved: approved, edited: edited}) }() var result approvalResult select { case result = <-resolveCh: case <-ctx.Done(): return "", ctx.Err() } if result.err != nil { return "", fmt.Errorf("plan_mode: approval: %w", result.err) } if !result.approved { // 用户拒绝: 保持 plan 模式 (让模型修改计划), 不恢复权限 return "", ErrPlanRejected } // 批准: 恢复权限模式 m.mu.Lock() m.active = false if perms != nil { perms.SetMode(prePlanMode) } m.mu.Unlock() // 如果用户编辑了计划, 写回存储 finalPlan := planContent if result.edited != "" { finalPlan = result.edited if writeErr := store.WritePlan(sessionID, result.edited); writeErr != nil { // 写回失败不阻塞审批结果, 记录但继续 _ = writeErr } } return finalPlan, nil } // ErrPlanRejected 表示用户拒绝了计划(不是系统错误). var ErrPlanRejected = fmt.Errorf("plan_mode: plan rejected by user") // ───────────────────────────────────────────── // EnterPlanModeTool // ───────────────────────────────────────────── // enterPlanModeTool 是 EnterPlanMode 内置工具的实现. // 模型调用此工具表示"我需要进入计划模式探索代码库并设计方案". // // 升华改进(ELEVATED): 早期方案 EnterPlanModeTool 通过 React context.setAppState 修改全局状态, // 并用 feature('KAIROS') 做功能开关(5 处 if 分支). // Go 实现直接操作 PlanModeManager,无全局状态,无功能开关-- // 消费方通过是否注册此工具来控制是否启用 plan 模式. // 替代方案:<用 Config.EnablePlanMode bool 开关> - // 否决原因:工具注册本身就是开关,无需额外 bool 字段. type enterPlanModeTool struct { manager *PlanModeManager } // NewEnterPlanModeTool 创建 EnterPlanMode 工具. // manager 是计划模式管理器(必须非 nil). func NewEnterPlanModeTool(manager *PlanModeManager) tools.Tool { return &enterPlanModeTool{manager: manager} } func (t *enterPlanModeTool) Name() string { return "EnterPlanMode" } func (t *enterPlanModeTool) Description(_ context.Context) string { return "进入计划模式:开始探索代码库并设计实现方案。适用于复杂任务,在实际编码前获得用户认可。" } func (t *enterPlanModeTool) InputSchema() json.RawMessage { // EnterPlanMode 不接受任何参数(早期方案同) return json.RawMessage(`{"type":"object","properties":{},"required":[]}`) } func (t *enterPlanModeTool) Execute(_ context.Context, _ json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { if err := t.manager.Enter(); err != nil { return &tools.Result{ Output: fmt.Sprintf("无法进入计划模式:%v", err), IsError: true, }, nil } t.manager.mu.Lock() planPath := t.manager.store.PlanPath(t.manager.sessionID) t.manager.mu.Unlock() // 返回给模型的指令文本(告诉模型接下来做什么) // 精妙之处(CLEVER): 指令文本直接成为模型的 tool_result, // 不需要额外的 system message 注入--模型会把 tool_result 当成上下文读取. // 这是早期方案 mapToolResultToToolResultBlockParam 的精髓:把"什么时候该做什么" // 写进工具结果,而不是提前写死在 system prompt 里(提前写的会被遗忘/覆盖). instructions := fmt.Sprintf(`已进入计划模式。 计划文件路径:%s 在计划模式中,请: 1. 使用 Glob、Grep、Read 工具彻底探索代码库 2. 理解现有的架构模式 3. 考虑多种方案及其权衡 4. 将你的实现计划写入上方的计划文件 5. 完成后使用 ExitPlanMode 提交计划请求审批 重要:计划模式下请勿编写或修改任何代码文件。这是只读探索和规划阶段。`, planPath) return &tools.Result{Output: instructions}, nil } // ───────────────────────────────────────────── // ExitPlanModeTool // ───────────────────────────────────────────── // exitPlanModeTool 是 ExitPlanMode 内置工具的实现. // 模型调用此工具表示"我已写完计划,请用户审批". // // 精妙之处(CLEVER): 工具执行会阻塞(通过 ApprovalPolicy.RequestApproval)直到用户审批. // 这意味着模型的下一轮循环要等用户操作--用户审批期间模型不会继续输出. // 早期方案用 shouldDefer: true 标记实现类似的延迟语义. // 替代方案:<非阻塞,通过事件通道异步审批> - // 否决原因:非阻塞需要工具链路两端都处理 "等待审批" 状态,复杂度倍增. // 阻塞更简单,且审批通常 <10s,对用户体验无影响. type exitPlanModeTool struct { manager *PlanModeManager } // NewExitPlanModeTool 创建 ExitPlanMode 工具. func NewExitPlanModeTool(manager *PlanModeManager) tools.Tool { return &exitPlanModeTool{manager: manager} } func (t *exitPlanModeTool) Name() string { return "ExitPlanMode" } func (t *exitPlanModeTool) Description(_ context.Context) string { return "退出计划模式:提交计划文件请求用户审批。仅在已将完整计划写入计划文件后调用。" } // InputSchema declares the ExitPlanMode tool's parameters. // // The plan markdown itself is NOT a parameter — it is read from PlanStore // (防注入, same as the early design). steps is an optional structured // step list the model may supply so downstream consumers (schedulers, // parallel orchestrators) can machine-parse dependencies rather than // guessing from free-form markdown. // // InputSchema 声明 ExitPlanMode 工具参数. 计划 markdown 本身不是参数 — // 从 PlanStore 读 (防注入, 对齐早期设计). steps 是模型可选提交的结构化 // 步骤列表, 让下游消费者 (调度器 / 并行协调器) 能机器解析依赖, 不再靠 // 自由 markdown 猜. func (t *exitPlanModeTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "steps": { "type": "array", "description": "Optional structured step list for downstream schedulers. Each item: id / description / tools / complexity (low|medium|high) / deps.", "items": { "type": "object", "properties": { "id": {"type": "string"}, "description": {"type": "string"}, "tools": {"type": "array", "items": {"type": "string"}}, "complexity": {"type": "string", "enum": ["low", "medium", "high"]}, "deps": {"type": "array", "items": {"type": "string"}} } } } }, "required": [] }`) } func (t *exitPlanModeTool) Execute(ctx context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { if !t.manager.IsActive() { // 防止在非 plan 模式下误调用 (早期方案有 validateInput 拦截同样情况) return &tools.Result{ Output: "你当前不在计划模式中。只有在 EnterPlanMode 后才能调用此工具。", IsError: true, }, nil } // Parse optional structured steps. A malformed or missing payload // degrades to nil — markdown-only plan still flows through normally. // 解析可选的结构化 steps. 缺失或格式不对时降级为 nil, markdown-only // 计划流程仍正常. var parsed struct { Steps []PlanStep `json:"steps"` } if len(input) > 0 { _ = json.Unmarshal(input, &parsed) } approvedPlan, err := t.manager.Exit(ctx, parsed.Steps) if err == ErrPlanRejected { // 用户拒绝:告知模型需要修改计划,保持 plan 模式 return &tools.Result{ Output: "用户拒绝了你的计划。请根据反馈修改计划文件,然后再次调用 ExitPlanMode。", }, nil } if err != nil { return &tools.Result{ Output: fmt.Sprintf("退出计划模式失败:%v", err), IsError: true, }, nil } t.manager.mu.Lock() planPath := t.manager.store.PlanPath(t.manager.sessionID) t.manager.mu.Unlock() // 审批通过:告知模型可以开始实现 // 对应早期方案 mapToolResultToToolResultBlockParam 的 approved 分支 var content string if approvedPlan == "" { content = "用户已批准退出计划模式。你现在可以继续。" } else { content = fmt.Sprintf(`用户已批准你的计划。你现在可以开始编码实现。 计划文件已保存至:%s 如有需要,可在实现过程中随时查阅。 ## 已批准的计划: %s`, planPath, approvedPlan) } return &tools.Result{Output: content}, nil }